diff --git a/.claude/commands/e2e-tests-apple.md b/.claude/commands/e2e-tests-apple.md new file mode 100644 index 000000000..e61cd58ad --- /dev/null +++ b/.claude/commands/e2e-tests-apple.md @@ -0,0 +1,53 @@ +--- +name: e2e-tests-apple +description: Run Apple-side device-backed OpenIAP regression on iOS using packages/apple and framework examples. Use when the user asks for iOS e2e tests or the Apple half of a parallel e2e run. +--- + +# E2E Tests Apple — iOS + +Run this for the Apple half of OpenIAP device regression, alone or in +parallel with `/e2e-tests-google`. For the full matrix in one run, use +`/e2e-tests` instead. For a delegated hardware run, use +`$e2e-matrix-runner-apple`. + +`e2e-tests.md` is the authority on what a row means and how to verify one. +Read it first and follow the sections named below; this file only narrows the +scope. + +## Scope + +- `packages/apple` native: `swift build` and `swift test`, plus the + xcframework build when iOS native packaging or bindings changed + (`## Package-Level Checks`, Apple package). +- iOS rows: all six frameworks (`react-native-iap`, `expo-iap`, + `flutter_inapp_purchase`, `kmp-iap`, `maui-iap`, `godot-iap`). +- Expo Onside: iOS build-only (`EXPO_IAP_ONSIDE=1` path under + `## Expo Checks`). +- Local (IAPKit) receipt vertical on iOS + (`## Local (IAPKit) Receipt Vertical`). + +That is the 7 iOS cells plus the Expo Onside build-only cell of the full +matrix. Report every unavailable row as `BLOCKED` with its exact missing +prerequisite. + +For each row, run the matching iOS sections under `## Expo Checks`, +`## React Native Checks`, `## Flutter Checks`, `## KMP Checks`, +`## MAUI Checks`, and `## Godot Checks`, then the iPhone bullet under +`## Manual Store Flows`. + +## Preflight + +Run the `## Preflight` block from `e2e-tests.md`, skipping only the +Android-only probes (`adb`, `vega`/`kepler`). + +## Parallel-run rules + +- Start the local IAPKit server once and share it with the Android half: + the iPhone verifies over the Mac's LAN address, Android over + `adb reverse tcp:3100`. +- Run only one Metro packager at a time across both halves (the RN/Expo + debug rows share port 8081); the devices otherwise proceed in parallel. + +## Final Report + +Use the `## Final Report` matrix in `e2e-tests.md`, iOS rows only. diff --git a/.claude/commands/e2e-tests-google.md b/.claude/commands/e2e-tests-google.md new file mode 100644 index 000000000..555fd97ce --- /dev/null +++ b/.claude/commands/e2e-tests-google.md @@ -0,0 +1,56 @@ +--- +name: e2e-tests-google +description: Run Android-side device-backed OpenIAP regression across Google Play, Amazon Appstore, Meta Horizon, and VegaOS using packages/google and framework examples. Use when the user asks for Android e2e tests or the Android half of a parallel e2e run. +--- + +# E2E Tests Google — Play / Amazon / Horizon / VegaOS + +Run this for the Android half of OpenIAP device regression, alone or in +parallel with `/e2e-tests-apple`. For the full matrix in one run, use +`/e2e-tests` instead. For a delegated hardware run, use +`$e2e-matrix-runner-google`. + +`e2e-tests.md` is the authority on what a row means and how to verify one. +Read it first and follow the sections named below; this file only narrows the +scope. + +## Scope + +- `packages/google` native: Play, Amazon, and Horizon compile plus tests + (`## Package-Level Checks`, Google Android package). +- Play rows: all six frameworks (`react-native-iap`, `expo-iap`, + `flutter_inapp_purchase`, `kmp-iap`, `maui-iap`, `godot-iap`). +- FireOS/Amazon rows: all six except `godot-iap`, which has no Amazon flavor. +- Horizon rows: all six except `godot-iap`, which has no Horizon flavor. + Horizon is build-only unless the request authorizes a visibly test/sandbox + checkout. +- VegaOS rows: `react-native-iap` and `expo-iap` only. +- Local (IAPKit) receipt vertical on Android + (`## Local (IAPKit) Receipt Vertical`). + +That is the 21 Android/Horizon cells plus the 2 VegaOS cells of the full +matrix. Report the two Godot gaps as `UNSUPPORTED` with the reason, never +omitted; every other unavailable row is `BLOCKED` with its exact missing +prerequisite. + +For each row, run the matching Android sections under `## Expo Checks`, +`## React Native Checks`, `## Flutter Checks`, `## KMP Checks`, +`## MAUI Checks`, and `## Godot Checks`, then the Android platform bullets +under `## Manual Store Flows` (Play, FireOS, VegaOS, Horizon). + +## Preflight + +Run the `## Preflight` block from `e2e-tests.md`, skipping only the iOS-only +probes (`xcrun`, `xcodebuild`). + +## Parallel-run rules + +- Start the local IAPKit server once and share it with the Apple half: + Android verifies over `adb reverse tcp:3100`, the iPhone over the Mac's LAN + address. +- Run only one Metro packager at a time across both halves (the RN/Expo + debug rows share port 8081); the devices otherwise proceed in parallel. + +## Final Report + +Use the `## Final Report` matrix in `e2e-tests.md`, Android rows only. diff --git a/.claude/commands/e2e-tests.md b/.claude/commands/e2e-tests.md index fbdab20b8..06d397116 100644 --- a/.claude/commands/e2e-tests.md +++ b/.claude/commands/e2e-tests.md @@ -8,6 +8,10 @@ description: Run device-backed OpenIAP regression across native packages and fra Run this when a PR or release candidate needs real-device regression across OpenIAP native packages and framework examples. +For a parallel run, split the matrix instead: `/e2e-tests-google` takes +Play, Amazon, Horizon, and VegaOS; `/e2e-tests-apple` takes iOS. Both read +this file as the row authority. + ## Scope Use the narrowest scope that satisfies the request, but broaden when native @@ -482,14 +486,16 @@ FireOS/Amazon Android path: ```bash cd libraries/expo-iap/example -EXPO_IAP_FIREOS=1 bunx expo prebuild --platform android --clean +bunx expo prebuild --platform android --clean cd android -variant_report="$(./gradlew :app:dependencyInsight \ +# The build follows the connected device; the inline pin keeps a build-only run +# exact without leaking into the next row. +variant_report="$(ORG_GRADLE_PROJECT_openiapStore=amazon ./gradlew :app:dependencyInsight \ --configuration debugRuntimeClasspath --dependency openiap-google)" printf '%s\n' "$variant_report" | grep -F 'Variant amazonDebugRuntimeElements' printf '%s\n' "$variant_report" | \ grep -E 'ProductFlavor:platform[[:space:]]+\| amazon' -./gradlew :app:assembleDebug +ORG_GRADLE_PROJECT_openiapStore=amazon ./gradlew :app:assembleDebug # Build-only regression can stop here. : "${FIREOS_SERIAL:?Set FIREOS_SERIAL to the target FireOS device serial}" adb -s "$FIREOS_SERIAL" install -r app/build/outputs/apk/debug/app-debug.apk @@ -500,14 +506,16 @@ Horizon Android build and optional device path: ```bash cd libraries/expo-iap/example -EXPO_IAP_HORIZON=1 bunx expo prebuild --platform android --clean +bunx expo prebuild --platform android --clean cd android -variant_report="$(./gradlew :app:dependencyInsight \ +# The build follows the connected device; the inline pin keeps a build-only run +# exact without leaking into the next row. +variant_report="$(ORG_GRADLE_PROJECT_openiapStore=horizon ./gradlew :app:dependencyInsight \ --configuration debugRuntimeClasspath --dependency openiap-google)" printf '%s\n' "$variant_report" | grep -F 'Variant horizonDebugRuntimeElements' printf '%s\n' "$variant_report" | \ grep -E 'ProductFlavor:platform[[:space:]]+\| horizon' -./gradlew :app:assembleDebug +ORG_GRADLE_PROJECT_openiapStore=horizon ./gradlew :app:assembleDebug # Build-only regression can stop here. : "${HORIZON_SERIAL:?Set HORIZON_SERIAL to the target Horizon device serial}" adb -s "$HORIZON_SERIAL" install -r app/build/outputs/apk/debug/app-debug.apk @@ -579,7 +587,7 @@ FireOS/Amazon Android build and launch smoke: ```bash cd libraries/react-native-iap/example/android -./gradlew :app:assembleDebug -PfireOsEnabled=true +./gradlew :app:assembleDebug -PopeniapStore=amazon # Build-only regression can stop here. : "${FIREOS_SERIAL:?Set FIREOS_SERIAL to the target FireOS device serial}" adb -s "$FIREOS_SERIAL" install -r app/build/outputs/apk/debug/app-debug.apk @@ -590,7 +598,7 @@ Horizon Android build-only path: ```bash cd libraries/react-native-iap/example/android -./gradlew :app:assembleDebug -PhorizonEnabled=true +./gradlew :app:assembleDebug -PopeniapStore=horizon ``` Normal iOS physical-device build and launch smoke: @@ -666,7 +674,7 @@ FireOS/Amazon Android build and launch smoke: ```bash cd libraries/flutter_inapp_purchase/example/android -./gradlew :app:assembleDebug -PfireOsEnabled=true +./gradlew :app:assembleDebug -PopeniapStore=amazon # Build-only regression can stop here. # Flutter redirects its gradle output to `example/build/app/outputs/flutter-apk/`, # so this path is not the `android/app/build/...` layout the other examples use. @@ -679,7 +687,7 @@ Horizon Android build-only path: ```bash cd libraries/flutter_inapp_purchase/example/android -./gradlew :app:assembleDebug -PhorizonEnabled=true +./gradlew :app:assembleDebug -PopeniapStore=horizon ``` iOS physical-device build and launch smoke: @@ -770,101 +778,29 @@ dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -p:TargetFrameworks=net10.0 -- dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -p:TargetFrameworks=net10.0-ios --nologo ``` -Normal Android / Play build and launch smoke: +Android build and launch smoke. The library is the same for every store; the +example build links one, from `OpenIapStore` or, on a Debug build, the device +`ANDROID_SERIAL` names. Its `openiap: store=... (source=...)` line says which. ```bash -# The MAUI-owned Android facade AAR output path is shared, so build it for the -# requested store immediately before the matching dotnet build. The library -# build disables ProjectReference rebuilds because the Android binding is built -# explicitly first. cd libraries/maui-iap/android -../../../packages/google/gradlew :openiap:assembleRelease -PopenIapAndroidStore=play +../../../packages/google/gradlew :openiap:assembleRelease cd .. -dotnet build-server shutdown || true -rm -rf \ - src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj \ - src/OpenIap.Maui/bin src/OpenIap.Maui/obj \ - example/OpenIap.Maui.Example/bin example/OpenIap.Maui.Example/obj -dotnet build src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj \ - -p:TargetFrameworks=net10.0-android \ - -p:OpenIapAndroidStore=play \ - --nologo -dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj \ - -p:TargetFrameworks=net10.0-android \ - -p:OpenIapAndroidStore=play \ - -p:BuildProjectReferences=false \ - --nologo +: "${ANDROID_SERIAL:?Set ANDROID_SERIAL to the Play, FireOS, or Horizon device}" dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ - -f net10.0-android \ - -p:OpenIapAndroidStore=play \ + -p:TargetFrameworks=net10.0-android \ -p:EmbedAssembliesIntoApk=true \ --nologo # Build-only regression can stop here. -: "${ANDROID_SERIAL:?Set ANDROID_SERIAL to the target Android device serial}" adb -s "$ANDROID_SERIAL" uninstall dev.hyo.martie || true adb -s "$ANDROID_SERIAL" install --no-incremental -r \ example/OpenIap.Maui.Example/bin/Debug/net10.0-android/dev.hyo.martie-Signed.apk adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1 ``` -FireOS/Amazon Android build and launch smoke: - -```bash -cd libraries/maui-iap/android -../../../packages/google/gradlew :openiap:assembleRelease -PopenIapAndroidStore=amazon -cd .. -dotnet build-server shutdown || true -rm -rf \ - src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj \ - src/OpenIap.Maui/bin src/OpenIap.Maui/obj \ - example/OpenIap.Maui.Example/bin example/OpenIap.Maui.Example/obj -dotnet build src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj \ - -p:TargetFrameworks=net10.0-android \ - -p:OpenIapAndroidStore=amazon \ - --nologo -dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj \ - -p:TargetFrameworks=net10.0-android \ - -p:OpenIapAndroidStore=amazon \ - -p:BuildProjectReferences=false \ - --nologo -dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ - -f net10.0-android \ - -p:OpenIapAndroidStore=amazon \ - -p:EmbedAssembliesIntoApk=true \ - --nologo -# Build-only regression can stop here. -: "${FIREOS_SERIAL:?Set FIREOS_SERIAL to the target FireOS device serial}" -adb -s "$FIREOS_SERIAL" uninstall dev.hyo.martie || true -adb -s "$FIREOS_SERIAL" install --no-incremental -r \ - example/OpenIap.Maui.Example/bin/stores/amazon/Debug/net10.0-android/dev.hyo.martie-Signed.apk -adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1 -``` - -Horizon Android build-only path: - -```bash -cd libraries/maui-iap/android -../../../packages/google/gradlew :openiap:assembleRelease -PopenIapAndroidStore=horizon -cd .. -dotnet build-server shutdown || true -rm -rf \ - src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj \ - src/OpenIap.Maui/bin src/OpenIap.Maui/obj \ - example/OpenIap.Maui.Example/bin example/OpenIap.Maui.Example/obj -dotnet build src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj \ - -p:TargetFrameworks=net10.0-android \ - -p:OpenIapAndroidStore=horizon \ - --nologo -dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj \ - -p:TargetFrameworks=net10.0-android \ - -p:OpenIapAndroidStore=horizon \ - -p:BuildProjectReferences=false \ - --nologo -dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ - -f net10.0-android \ - -p:OpenIapAndroidStore=horizon \ - --nologo -``` +Run it once per store device. Without a device, pin the store instead: +`-p:OpenIapStore=horizon`. `bash scripts/verify-store-selection.sh` covers the +selection rule itself without hardware. iOS physical-device build and launch smoke: @@ -901,6 +837,11 @@ adb -s "$ANDROID_SERIAL" install -r Example/android/Martie.apk adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1 ``` +A headless Godot export runs `adb kill-server` on exit (editor setting +`export/android/shutdown_adb_on_exit`, on by default). That drops every +`adb reverse` rule and scrcpy session on every device, so re-create the +`tcp:3100` rule before verifying, and export before the other Android rows. + iOS build and launch smoke: ```bash diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md index d21dffd06..99c4116b4 100644 --- a/.claude/commands/verify-all.md +++ b/.claude/commands/verify-all.md @@ -151,25 +151,18 @@ bun run audit:commerce-evidence || echo "commerce evidence differs from current ( cd libraries/maui-iap DOTNET_BUILD_ARGS=(/m:1 /nr:false -p:UseSharedCompilation=false --nologo) - for store in play amazon horizon; do - dotnet build-server shutdown || true - rm -rf \ - src/OpenIap.Maui.Bindings.Android/bin \ - src/OpenIap.Maui.Bindings.Android/obj - (cd android && ../../../packages/google/gradlew \ - :openiap:assembleRelease -PopenIapAndroidStore="$store") - dotnet build \ - src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj \ - -p:TargetFrameworks=net10.0-android \ - -p:OpenIapAndroidStore="$store" \ - "${DOTNET_BUILD_ARGS[@]}" - dotnet build \ - src/OpenIap.Maui/OpenIap.Maui.csproj \ - -p:TargetFrameworks=net10.0-android \ - -p:OpenIapAndroidStore="$store" \ - -p:BuildProjectReferences=false \ - "${DOTNET_BUILD_ARGS[@]}" - done + (cd android && ../../../packages/google/gradlew :openiap:assembleRelease) + dotnet build \ + src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj \ + -p:TargetFrameworks=net10.0-android \ + "${DOTNET_BUILD_ARGS[@]}" + dotnet build \ + src/OpenIap.Maui/OpenIap.Maui.csproj \ + -p:TargetFrameworks=net10.0-android \ + -p:BuildProjectReferences=false \ + "${DOTNET_BUILD_ARGS[@]}" + # The app build picks the store; this checks every rule against a fake adb. + bash scripts/verify-store-selection.sh ) # MAUI iOS/macCatalyst binding and platform library (requires xcodegen + MAUI workload) diff --git a/.claude/skills/e2e-matrix-runner-apple/SKILL.md b/.claude/skills/e2e-matrix-runner-apple/SKILL.md new file mode 100644 index 000000000..57344b214 --- /dev/null +++ b/.claude/skills/e2e-matrix-runner-apple/SKILL.md @@ -0,0 +1,20 @@ +--- +name: e2e-matrix-runner-apple +description: Run the Apple half of the OpenIAP device matrix — six frameworks plus the native package on iOS — on a physical iPhone and report one row per cell with evidence. Use when asked for iOS e2e or delegated hardware testing. +--- + +# E2E Matrix Runner Apple (Claude Code) + +The canonical procedure lives in +`.codex/skills/e2e-matrix-runner-apple/SKILL.md`. Read that file first and +follow it fully — the narrowed matrix, the parallel-run rules, and its +references to the full skill's driving techniques and reporting contract are +all defined there. + +## Claude Code notes + +- `.claude/commands/e2e-tests-apple.md` scopes this run; + `.claude/commands/e2e-tests.md` is the authority on what an individual row + means. +- Prefer the repo's own scripts over ad-hoc commands, and run long builds in the + background so the session stays responsive. diff --git a/.claude/skills/e2e-matrix-runner-google/SKILL.md b/.claude/skills/e2e-matrix-runner-google/SKILL.md new file mode 100644 index 000000000..5aa73d6cb --- /dev/null +++ b/.claude/skills/e2e-matrix-runner-google/SKILL.md @@ -0,0 +1,20 @@ +--- +name: e2e-matrix-runner-google +description: Run the Android half of the OpenIAP device matrix — six frameworks across Google Play, Amazon Appstore, and Meta Horizon, plus VegaOS — on real hardware and report one row per cell with evidence. Use when asked for Android e2e or delegated hardware testing. +--- + +# E2E Matrix Runner Google (Claude Code) + +The canonical procedure lives in +`.codex/skills/e2e-matrix-runner-google/SKILL.md`. Read that file first and +follow it fully — the narrowed matrix, the parallel-run rules, and its +references to the full skill's driving techniques and reporting contract are +all defined there. + +## Claude Code notes + +- `.claude/commands/e2e-tests-google.md` scopes this run; + `.claude/commands/e2e-tests.md` is the authority on what an individual row + means. +- Prefer the repo's own scripts over ad-hoc commands, and run long builds in the + background so the session stays responsive. diff --git a/.claude/skills/e2e-matrix-runner/SKILL.md b/.claude/skills/e2e-matrix-runner/SKILL.md new file mode 100644 index 000000000..b9bdbfe4e --- /dev/null +++ b/.claude/skills/e2e-matrix-runner/SKILL.md @@ -0,0 +1,18 @@ +--- +name: e2e-matrix-runner +description: Run the full OpenIAP device matrix — six frameworks across iOS, Google Play, Amazon Appstore, Meta Horizon, and VegaOS — on real hardware and report one row per cell with evidence. Use when asked for a full e2e matrix or delegated hardware testing. +--- + +# E2E Matrix Runner (Claude Code) + +The canonical procedure lives in `.codex/skills/e2e-matrix-runner/SKILL.md`. +Read that file first and follow it fully — the matrix, the device inventory, the +Quest virtual-display technique, the local IAPKit rules, the credential +boundary, and the reporting contract are all defined there. + +## Claude Code notes + +- `.claude/commands/e2e-tests.md` is the authority on what an individual row + means; the matrix skill only adds scope and delegation rules. +- Prefer the repo's own scripts over ad-hoc commands, and run long builds in the + background so the session stays responsive. diff --git a/.claude/skills/opencollective-steward/SKILL.md b/.claude/skills/opencollective-steward/SKILL.md index 6f093b2e8..2e8e9f5d2 100644 --- a/.claude/skills/opencollective-steward/SKILL.md +++ b/.claude/skills/opencollective-steward/SKILL.md @@ -17,6 +17,6 @@ are agent-agnostic and apply as written. Chrome) with the user's signed-in session. If OpenCollective asks for sign-in, hand control back to the user; never infer or extract auth tokens from browser state. -- The Trix-editor guardrail applies verbatim: verify the hidden form input - matches the visible editor content before pressing Save, and abort the save - when they disagree. +- The canonical Live Edit Guardrails apply verbatim: confirm the form will + submit the new copy before pressing Save, and read it back from the public + API afterwards. diff --git a/.claude/skills/openiap-workflows/SKILL.md b/.claude/skills/openiap-workflows/SKILL.md index 665737841..fdb8c1b19 100644 --- a/.claude/skills/openiap-workflows/SKILL.md +++ b/.claude/skills/openiap-workflows/SKILL.md @@ -27,6 +27,8 @@ reading the command file (or invoke the slash command directly when available): - Resolve a GitHub issue → `.claude/commands/resolve-issue.md` (`/resolve-issue`) - Verify all / monorepo health check → `.claude/commands/verify-all.md` (`/verify-all`) - Device-backed E2E regression → `.claude/commands/e2e-tests.md` (`/e2e-tests`) +- Android device-backed E2E regression → `.claude/commands/e2e-tests-google.md` (`/e2e-tests-google`) +- Apple device-backed E2E regression → `.claude/commands/e2e-tests-apple.md` (`/e2e-tests-apple`) - Stable or RC/next releases → `.claude/commands/release.md` (`/release`) - Commit, push, or create PR → `.claude/commands/commit.md` (`/commit`) diff --git a/.codex/skills/e2e-matrix-runner-apple/SKILL.md b/.codex/skills/e2e-matrix-runner-apple/SKILL.md new file mode 100644 index 000000000..f911ea945 --- /dev/null +++ b/.codex/skills/e2e-matrix-runner-apple/SKILL.md @@ -0,0 +1,37 @@ +--- +name: e2e-matrix-runner-apple +description: Run the Apple half of the OpenIAP device matrix — six frameworks plus the native package on iOS — on a physical iPhone and report one row per cell with evidence. Use when asked for iOS e2e or the Apple half of a parallel device run. +--- + +# E2E Matrix Runner Apple + +Run every cell of the matrix below on a physical iPhone and report a row for +each. Run alone or in parallel with `$e2e-matrix-runner-google`; for the +full matrix in one run, use `$e2e-matrix-runner`. + +`.claude/commands/e2e-tests-apple.md` scopes this run and +`.claude/commands/e2e-tests.md` is the authority on what a row means and how +to verify one — read both before starting and follow them. The full +`.codex/skills/e2e-matrix-runner/SKILL.md` is the authority on driving +technique, credentials, and reporting: its iOS section, device table, +local-receipt rules, and standing approvals all apply unchanged. This file +adds only the narrowed matrix. + +## The matrix (pinned scope — do not renegotiate per run) + +| Store | Frameworks | Device | Depth | +| ----- | ---------------------------------- | ----------------- | ------------------------- | +| iOS | all six + `packages/apple` | iPhone (physical) | device purchase flow each | +| iOS | Expo Onside (`EXPO_IAP_ONSIDE=1`) | generic iOS build | build-only | + +That is the 7 iOS cells plus the Expo Onside build-only cell of the full +matrix. All six example apps share the bundle id `dev.hyo.martie`, so +install, purchase, and move to the next framework strictly one at a time. + +## Parallel-run rules + +- Share one local IAPKit server with the Android half: the iPhone verifies + over the Mac's LAN address (confirm with `ipconfig getifaddr en1`, + fallback `en0`, each run), Android over `adb reverse tcp:3100`. +- Run only one Metro packager at a time across both halves (the RN/Expo + debug rows share port 8081); the devices otherwise proceed in parallel. diff --git a/.codex/skills/e2e-matrix-runner-google/SKILL.md b/.codex/skills/e2e-matrix-runner-google/SKILL.md new file mode 100644 index 000000000..dc213eb94 --- /dev/null +++ b/.codex/skills/e2e-matrix-runner-google/SKILL.md @@ -0,0 +1,39 @@ +--- +name: e2e-matrix-runner-google +description: Run the Android half of the OpenIAP device matrix — six frameworks across Google Play, Amazon Appstore, and Meta Horizon, plus VegaOS — on real hardware and report one row per cell with evidence. Use when asked for Android e2e or the Android half of a parallel device run. +--- + +# E2E Matrix Runner Google + +Run every cell of the matrix below on real hardware and report a row for +each. Run alone or in parallel with `$e2e-matrix-runner-apple`; for the full +matrix in one run, use `$e2e-matrix-runner`. + +`.claude/commands/e2e-tests-google.md` scopes this run and +`.claude/commands/e2e-tests.md` is the authority on what a row means and how +to verify one — read both before starting and follow them. The full +`.codex/skills/e2e-matrix-runner/SKILL.md` is the authority on driving +technique, credentials, and reporting: its Android, Quest, and VegaOS +sections, device table, local-receipt rules, and standing approvals all +apply unchanged. This file adds only the narrowed matrix. + +## The matrix (pinned scope — do not renegotiate per run) + +| Store | Frameworks | Device | Depth | +| ------------ | --------------------------------------- | ----------- | ---------------------------- | +| Google Play | all six + `packages/google` | Pixel | device purchase flow each | +| Amazon | all six except Godot + `packages/google` | Fire tablet | device purchase flow each | +| Meta Horizon | all six except Godot + `packages/google` | Quest 3 | build + install + launch; purchase only when the checkout UI is visibly test/sandbox | +| VegaOS | react-native-iap and expo-iap only | Vega device | build + install + launch; purchase attempt when device input allows | + +That is the 21 Android/Horizon cells plus the 2 VegaOS cells of the full +matrix. Godot has no Amazon or Horizon flavor in its Android plugin, so +report both cells as `UNSUPPORTED` with the reason, never omitted. + +## Parallel-run rules + +- Share one local IAPKit server with the Apple half: Android verifies over + `adb reverse tcp:3100`, the iPhone over the Mac's LAN address. Re-check the + reverse mapping immediately before each purchase. +- Run only one Metro packager at a time across both halves (the RN/Expo + debug rows share port 8081); the devices otherwise proceed in parallel. diff --git a/.codex/skills/e2e-matrix-runner/SKILL.md b/.codex/skills/e2e-matrix-runner/SKILL.md new file mode 100644 index 000000000..1b63ce033 --- /dev/null +++ b/.codex/skills/e2e-matrix-runner/SKILL.md @@ -0,0 +1,242 @@ +--- +name: e2e-matrix-runner +description: Run the full OpenIAP device matrix — six frameworks across iOS, Google Play, Amazon Appstore, Meta Horizon, and VegaOS — driving real hardware over adb and xcrun, and report one row per cell with evidence. Use when asked for a full e2e matrix, a device regression across every framework and store, or delegated hardware testing. +--- + +# E2E Matrix Runner + +Run every cell of the matrix below on real hardware and report a row for each. +`.claude/commands/e2e-tests.md` is the authority on what a row means and how to +verify one; read it before starting and follow it. This file adds only what a +delegated agent needs: the matrix, the devices, and the rules for reporting. + +For a parallel run, delegate `$e2e-matrix-runner-google` (Play, Amazon, +Horizon, VegaOS) and `$e2e-matrix-runner-apple` (iOS) instead; both follow +this file's techniques and reporting contract on their narrowed scope. + +## The matrix (pinned scope — do not renegotiate per run) + +Six frameworks: `react-native-iap`, `expo-iap`, `flutter_inapp_purchase`, +`kmp-iap`, `maui-iap`, `godot-iap`, plus the native `packages/google` +(Android) and `packages/apple` (iOS) rows. + +| Store | Frameworks | Device | Depth | +| ------------ | ---------------------------------- | ----------------- | ---------------------------- | +| iOS | all six + `packages/apple` | iPhone (physical) | device purchase flow each | +| Google Play | all six + `packages/google` | Pixel | device purchase flow each | +| Amazon | all six + `packages/google` | Fire tablet | device purchase flow each | +| Meta Horizon | all six + `packages/google` | Quest 3 | build + install + launch + tap-navigate to the purchase gate; purchase only when the checkout UI is visibly test/sandbox | +| VegaOS | react-native-iap and expo-iap only | Vega device | build + install + launch; purchase attempt when device input allows | + +That is 7 iOS + 21 Android/Horizon + 2 VegaOS cells. Do not silently drop a +cell. Known exceptions, reported as `UNSUPPORTED` with the reason, never +omitted: Godot has no Horizon flavor in its Android plugin, so the +Godot/Horizon cell cannot build. + +## Standing approvals for E2E runs + +- Sandbox/test purchases on Play, Amazon, and iOS are pre-approved: tap the + purchase sheet, type the pinned sandbox password below, and finish the + transaction without asking. +- Horizon checkout is real money: take the `Confirm` tap only when its UI is + visibly marked test or sandbox. Otherwise report build + launch coverage. +- VegaOS: run at least one purchase attempt when device input and tester UI + are available. + +## Devices attached to this machine + +Discover them rather than trusting this list, with `adb devices -l` and +`xcrun devicectl list devices`. At the time of writing: + +| Role | Serial / UDID | +| ----------- | --------------------------- | +| Pixel | `HT79F1A00473` | +| Fire tablet | `GN43T503515200BA` | +| Quest 3 | `2G0YC5ZG480381` | +| Vega device | `G0733M085512021G` | +| iPhone | `00008110-0004081E1A79801E` | + +The iPhone UDID has changed mid-session before (re-enumeration); re-check it +after any `device was not found` error instead of retrying the stale id. The +Mac's LAN address (the iPhone's IAPKit/Metro origin) also changes between +networks — confirm with `ipconfig getifaddr en1` (fallback `en0`) each run. + +Every example shares the application id `dev.hyo.martie`, so only one framework +can be installed at a time per device. Uninstall before installing the next, and +expect `INSTALL_FAILED_UPDATE_INCOMPATIBLE` when signing keys differ. This +applies to iOS too: all six example apps share the bundle id, so install, +purchase, and move to the next framework strictly one at a time. + +## Driving the hardware + +**Android.** `adb -s install -r `, `adb -s shell input +tap X Y`, and `adb -s exec-out screencap -p > shot.png`. Read the +screenshot before every tap; do not tap coordinates from memory. + +**Quest.** `screencap` returns black because Quest blocks capture of the VR +compositor. Do not conclude the device is undriveable. Drive the display-0 VR +panel directly: `adb -s $QUEST shell input tap X Y` reaches the panel, and +`adb -s $QUEST shell uiautomator dump` exposes the accessibility tree with +text and bounds — dump, tap the dumped coordinates, dump again. `tap`, `swipe`, +and `keyevent` (BACK dismisses the Horizon checkout dialog) are all verified +working on display 0 (Quest 3, Horizon OS, 2026-09-25: RN RedBox dismissed, +Kepler home → Purchase Flow navigated, MAUI scrolled to products, Purchase +tapped into `com.oculus.store` checkout and BACKed out cleanly). + +```bash +adb -s "$QUEST" shell am start -n # resolve per framework +adb -s "$QUEST" shell uiautomator dump /sdcard/ui.xml +adb -s "$QUEST" pull /sdcard/ui.xml . +adb -s "$QUEST" shell input tap X Y # coordinates from the dump +``` + +Resolve the launcher activity per framework (`cmd package resolve-activity +--brief ...`, or `monkey -p -c android.intent.category.LAUNCHER 1`): +Flutter uses `io.flutter.embedding.android.FlutterActivity`, MAUI a +`crc...MainActivity`. RN/Expo debug builds need their Metro +(`adb reverse tcp:8081`, one packager at a time) and a cold start if the +first bundle load stalls. If Metro serves a stale graph (same RedBox after +an entry change, or a 500 `Got unexpected undefined`), restart it with +`--reset-cache`. When the panel is empty right after launch, wait for the JS +bundle (RN shows 6 bare nodes until loaded); a transient `null root node` +from the dump usually clears on retry. + +Verified limitation (Quest 3, Horizon OS, scrcpy 3.x): the above applies to +display 0 only. On a scrcpy virtual display (`--new-display`), touch +injection is silently ignored — `adb shell input -d N tap`, explicit +`input touchscreen -d N tap`, and monkey-script `tap(x,y)` all leave the +frame bit-identical (compare md5 before/after). Key events +(`input -d N keyevent`) do reach the app. Prefer display 0 + dumps over the +virtual-display + screenshot path; one md5-compare per OS upgrade is enough, +do not burn the run re-proving it. + +Consequences: drive each Horizon app as install + launch + navigate + tap to +the purchase gate. The `com.oculus.store` checkout dialog renders its full +text into the dump (product, total, payment method, Confirm), so the +test/sandbox gate stays enforceable without screenshots. Frameworks without +a Horizon commerce module fail earlier with their own store error (RN-IAP: +`initConnection failed ... responseCode -1`, no Play Store on Horizon OS) — +report that exact error, not a generic input block. Horizon purchase cells +are `BLOCKED` by default (real-money Confirm, or no store connection), never +guessed. + +**iOS.** Build with `xcodebuild -destination "id=$UDID"`, install with +`xcrun devicectl device install app`, launch with +`xcrun devicectl device process launch`. Never use iPhone Mirroring for +debugging or purchases: the phone stays in the user's hand, and Mirror refuses +to connect while it is in use. Drive the physical iPhone directly instead. + +A physical iPhone _can_ be driven, through XCUITest. Build a UI-test bundle once +and point it at any installed app with `XCUIApplication(bundleIdentifier:)`, then +run it with `xcodebuild test-without-building -xctestrun`, passing the flow in +environment variables so one signed runner serves every framework. Without an +Xcode account, build with `CODE_SIGNING_ALLOWED=NO` and hand-sign the runner and +its nested `.xctest` with a wildcard development profile. + +Shortcut when the custom runner is not at hand: `maestro-runner` drives a +physical iPhone over its bundled WebDriverAgent with Maestro YAML flows as-is. +Verified working on this machine (Korea's iPhone, iOS 27, team PRDQGB267K): + +```bash +export PATH="$HOME/.maestro-runner/bin:$PATH" +maestro-runner --platform ios --device "$IOS_UDID" --team-id PRDQGB267K \ + test flow.yaml +``` + +Rules for this path, all verified the hard way: + +- Do NOT pass `--wda-bundle-id`: a custom bundle forces a rebuild that fails + signing (`No Accounts`, stale wildcard profile). The default bundle reuses + the good cache under `~/.maestro-runner/cache/wda-builds/`. +- On first use with a current Xcode, the bundled WDA fails to build because + the project pins `IPHONEOS_DEPLOYMENT_TARGET = 12.0` (below Xcode's 15.0 + floor). Patch once in the user-local checkout and rerun: + `sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = 12\.0/IPHONEOS_DEPLOYMENT_TARGET = 15.0/g' ~/.maestro-runner/drivers/ios/WebDriverAgent/WebDriverAgent.xcodeproj/project.pbxproj` +- `takeScreenshot` only saves plain filenames (`shot.png` lands under the + run's `assets/` dir). Absolute paths like `/tmp/x.png` fetch fine over WDA + but fail to save (`no such file or directory`) while the step still passes — + a silent evidence loss. Always confirm the PNG exists before claiming a + visual check. +- A flow `test/...` writes `reports//` with `report.json` + (`status: passed`), `junit-report.xml`, and per-command assets. The suite + exit code is unreliable alone; read `report.json` for the verdict. + +Two gates need a human, roughly once a day each: the device asks for its passcode +to _Enable UI Automation_, and a sandbox purchase can demand a hardware +**side-button double-click**. Neither is automatable. Report that cell as +`BLOCKED: needs ` and keep going. + +Traps that look like code bugs: + +- **Reinstalling resets Local Network permission.** Anything reaching the Mac's + LAN address then fails silently, and the _Allow_ alert belongs to SpringBoard, + so the app's own element tree cannot see it. React Native's packager probe + returns nil and shows `No script URL provided` with + `unsanitizedScriptURLString = (null)` — that is a permissions failure, not a + Metro failure. Drive `XCUIApplication(bundleIdentifier: "com.apple.springboard")` + and tap _Allow_. +- **The phone cannot reach `127.0.0.1`.** iOS has no `adb reverse`. Use the Mac's + LAN address for both Metro and the IAPKit server, and start the packager with + `REACT_NATIVE_PACKAGER_HOSTNAME= ... --host lan`. +- **Expo bakes `extra` into the binary at native build time.** Editing `.env` and + restarting with `--clear` changes nothing; confirm the value in the installed + app's `EXConstants.bundle/app.config` and rebuild natively. +- **Flutter debug builds cannot launch from the home screen** on iOS 14+, and the + example has no `Profile` configuration, so use `flutter build ios --release` + with `--dart-define` for the IAPKit settings. +- **Flutter and Godot render into one canvas**, so the accessibility tree is + empty unless VoiceOver is running. Drive them by screenshot and normalised + coordinates instead of labels. +- **`dotnet build` ships a stale `Info.plist` incrementally.** After editing it, + delete `bin/` and `obj/` for that target framework or the device keeps running + the old plist. + +**VegaOS.** Source `~/vega/env` first. `vega exec vda devices -l` is transport +visibility only; `kepler device list` is the install source of truth. Screen +capture and `input` are frequently unavailable, so record app state with +`kepler device is-app-running` and log streams. + +## Local receipt verification + +Rows verify against a local IAPKit server, not the hosted one. Start it from +`packages/kit` as `.claude/commands/e2e-tests.md` describes, give each Android +device `adb -s reverse --no-rebind tcp:3100 tcp:3100`, and point the +example at `http://127.0.0.1:3100`; a physical iPhone needs the Mac's LAN +address instead. **Re-check the reverse mapping immediately before each +purchase** — it is dropped whenever a device reconnects, and the symptom is a +verification failure that looks like a code bug. + +A row passes only when the server logs a matching `verify_request` with +`isValid: true` and the app finishes the transaction. Record the `corrId`. + +## Credentials (pinned — standing user override) + +The TestFlight/sandbox Apple Account password is pinned: `Password12!`. When a +sandbox purchase sheet asks for it, type it and continue the cell without +asking. This is a shared test account, so no redaction or secrecy handling is +needed in transcripts, logs, or screenshots. + +What stays human-only: the device passcode for _Enable UI Automation_, the +hardware side-button double-click, a parental-control PIN, and any real-money +Horizon checkout. If one of those blocks a cell, report it as +`BLOCKED: needs ` and keep going. Never invent or reuse the pinned +password for any other account. + +## Reporting + +One row per cell, in a table, with: + +- framework, store, device serial +- `PASS` / `FAIL` / `BLOCKED` / `UNSUPPORTED` +- evidence: the store transaction id and the server `corrId` for a pass, the + exact error for a fail, the exact missing prerequisite for a blocked cell + +Rules that matter more than finishing: + +- A build is not a purchase. Say which you did. +- Never report a cell green without a passing command or a concrete device + result you observed. +- A cell you could not run is `BLOCKED` with the reason, never omitted and never + guessed. +- If a build fails, fix it if the fix is obvious and in scope, then rerun; if + not, report the failure with its output. diff --git a/.codex/skills/iapkit-e2e-martie/SKILL.md b/.codex/skills/iapkit-e2e-martie/SKILL.md index 7404180aa..b296c0494 100644 --- a/.codex/skills/iapkit-e2e-martie/SKILL.md +++ b/.codex/skills/iapkit-e2e-martie/SKILL.md @@ -266,17 +266,21 @@ same local-server and purchases-view evidence as the other live lanes. Every item below has cost a full debugging session. Check them before concluding that a store, an account, or the code is at fault. -**The Android project keeps the last store it was prebuilt for.** The FireOS -and Horizon rows run `expo prebuild --platform android --clean` with -`EXPO_IAP_FIREOS=1` or `EXPO_IAP_HORIZON=1`, and the generated `android/` -directory keeps that store afterwards. A later Play run then links the wrong -`openiap-google` flavor, so the example sits on `Connecting to Store...` with +**A leftover store pin outranks the device.** The FireOS and Horizon rows need +no store variable: the build follows the connected device. A pin left behind +(`ORG_GRADLE_PROJECT_openiapStore` still exported, `EXPO_IAP_HORIZON` or +`EXPO_IAP_FIREOS` still exported, or an `openiapStore` line written by the +deprecated `modules.horizon` / `modules.amazon.fireOS` options) +makes a later Play run link the wrong `openiap-google` flavor, so the example +sits on `Connecting to Store...` with `initConnection failed: Failed to initialize connection` and -`getStorefront failed: Billing client not ready`. Re-run -`bunx expo prebuild --platform android --clean` with no store variable before -the Play row, then confirm `horizonEnabled=false` and `fireOsEnabled=false` in -`android/gradle.properties` and `missingDimensionStrategy "platform", "play"` -in `android/app/build.gradle`. +`getStorefront failed: Billing client not ready`. Unset those variables, re-run +`bunx expo prebuild --platform android --clean` before the Play row, then +confirm `android/gradle.properties` carries no +`openiapStore` pin (and none of the legacy `horizonEnabled` / `fireOsEnabled` +flags) and that `android/app/build.gradle` has no fixed +`missingDimensionStrategy`; the Gradle build then resolves the store from the +connected device or defaults to Play. **A Play "not compatible with your device" banner does not block billing.** The Martie production listing sets `minSdkVersion 31`, so Play marks an Android 11 diff --git a/.codex/skills/loop-review/SKILL.md b/.codex/skills/loop-review/SKILL.md index 1f3084042..23cedfcc2 100644 --- a/.codex/skills/loop-review/SKILL.md +++ b/.codex/skills/loop-review/SKILL.md @@ -70,6 +70,10 @@ Keep generated files, documentation, previews, and knowledge context in sync through their canonical workflows. Do not proceed while the working diff has a known failing required check. +Once it works, clean it up before review: apply "Clean Up Once It Works" in +`knowledge/internal/03-coding-style.md` to the diff and to smells met along the +way, without waiting to be asked, then rerun the affected checks. + ## 3. Stabilize With Review Self Run `$review-self` immediately against the complete base-to-working-tree diff. diff --git a/.codex/skills/opencollective-steward/SKILL.md b/.codex/skills/opencollective-steward/SKILL.md index 123e2153f..904a10d7f 100644 --- a/.codex/skills/opencollective-steward/SKILL.md +++ b/.codex/skills/opencollective-steward/SKILL.md @@ -19,10 +19,14 @@ Use this skill to treat OpenCollective as an ongoing community-support surface f ## Live Edit Guardrails -- On OpenCollective profile forms, verify that rich-text editor changes update the - backing hidden input before pressing Save. If the visible Trix editor changes - but the hidden value still contains the previous copy, do not save; the form - can submit stale content. +- Edit the About copy at `https://opencollective.com/dashboard/openiap/info`; + the public profile has no edit control for it. +- Before pressing Save on a rich-text field, confirm the form will submit the + new copy. On the dashboard Info page the hidden input keeps the loaded copy + because React pins its value, so read the editor component's `value` prop + instead. If it does not hold the new copy, do not save. +- After saving, read the field back from the public GraphQL API + (`account(slug: "openiap") { longDescription }`) and compare it with the copy. - If browser editor state will not sync, use an explicit OpenCollective API token or ask the maintainer to paste the prepared copy manually. Do not infer auth tokens from browser state. diff --git a/.codex/skills/openiap-workflows/SKILL.md b/.codex/skills/openiap-workflows/SKILL.md index d25f2ab5b..3b6c91c40 100644 --- a/.codex/skills/openiap-workflows/SKILL.md +++ b/.codex/skills/openiap-workflows/SKILL.md @@ -50,6 +50,10 @@ natural-language requests, execute the matching workflow: `.claude/commands/verify-all.md`. - E2E tests, device regression, connected-device purchase flow checks, or "e2e-tests": read `.claude/commands/e2e-tests.md`. +- Android E2E tests, Play/Amazon/Horizon/VegaOS regression, or + "e2e-tests-google": read `.claude/commands/e2e-tests-google.md`. +- Apple E2E tests, iOS regression, or "e2e-tests-apple": read + `.claude/commands/e2e-tests-apple.md`. - Stable releases, RC/next releases, registry publication, or package deploys: read `.claude/commands/release.md`. - Commit, push, or create PR: read `.claude/commands/commit.md`. @@ -60,6 +64,7 @@ instruction narrows the scope. For `e2e-tests`, an unqualified request means the full regression matrix in the command file, including native packages, framework libraries, build-only platform rows, connected-device rows, and explicit blocked/unsupported rows. +A request naming one platform runs only that half's scoped file. ## Internal Workflow Change Guard diff --git a/.codex/skills/review-self/SKILL.md b/.codex/skills/review-self/SKILL.md index 9d6880ebc..f4c344649 100644 --- a/.codex/skills/review-self/SKILL.md +++ b/.codex/skills/review-self/SKILL.md @@ -54,13 +54,16 @@ result is stable. - missing or weak tests, documentation, examples, migrations, and operational safeguards required by the change; - the canonical KISS/SSOT release rules in - `knowledge/internal/03-coding-style.md`. + `knowledge/internal/03-coding-style.md`; + - code smells in the diff, the code it touches, and any code read during + the round, including any `as any`, per "Clean Up Once It Works" in the + same file. These are in scope and fixed without being asked. 4. Use independent read-only subagents for separate review lenses when the diff is large or cross-cutting. Give them the raw target and request, not suspected findings or an expected answer. 5. Validate every finding against the current code and applicable instructions. - Reject pure taste, cosmetic churn, duplicate findings, and unrelated - nice-to-have work. + Reject pure taste, cosmetic churn, duplicate findings, and unrelated feature + work. A code smell is not taste: fix it even outside the change's scope. 6. Fix all validated in-scope findings in one coherent batch under those canonical KISS/SSOT rules. Regenerate generated files only through their documented generator and preserve unrelated edits. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..f31c7702c --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +# The self-hosted Mac runner's label, so actionlint does not report it as unknown. +self-hosted-runner: + labels: + - xcode-27 diff --git a/.github/workflows/ci-expo-iap.yml b/.github/workflows/ci-expo-iap.yml index 8a82681ee..cf3550bfb 100644 --- a/.github/workflows/ci-expo-iap.yml +++ b/.github/workflows/ci-expo-iap.yml @@ -9,6 +9,7 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" - "scripts/test-ios-log-sanitizers.sh" @@ -22,6 +23,7 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" - "scripts/test-ios-log-sanitizers.sh" diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index 6ed9bc586..e57ba63f2 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -9,6 +9,7 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-flutter-inapp-purchase.yml" @@ -23,6 +24,7 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-flutter-inapp-purchase.yml" @@ -107,7 +109,7 @@ jobs: working-directory: libraries/flutter_inapp_purchase/example run: | flutter pub get - ./android/gradlew -p android :app:processDebugManifest -PhorizonEnabled=true + ./android/gradlew -p android :app:processDebugManifest -PopeniapStore=horizon node "$GITHUB_WORKSPACE/scripts/verify-horizon-merged-manifest.mjs" \ build/app/intermediates/merged_manifests/debug/processDebugManifest/AndroidManifest.xml diff --git a/.github/workflows/ci-godot-iap.yml b/.github/workflows/ci-godot-iap.yml index 9261a3fb9..f4e935f7b 100644 --- a/.github/workflows/ci-godot-iap.yml +++ b/.github/workflows/ci-godot-iap.yml @@ -8,6 +8,7 @@ on: - "scripts/ci/retry-gradle.sh" - "scripts/fetch-godot-lib.sh" - "libraries/godot-iap/**" + - "packages/google/compatibility/store-resolver/fake-adb" - "specs/client/codegen/plugins/gdscript.ts" - "specs/client/src/generated/types.gd" - "openiap-versions.json" @@ -19,6 +20,7 @@ on: - "scripts/ci/retry-gradle.sh" - "scripts/fetch-godot-lib.sh" - "libraries/godot-iap/**" + - "packages/google/compatibility/store-resolver/fake-adb" - "specs/client/codegen/plugins/gdscript.ts" - "specs/client/src/generated/types.gd" - "openiap-versions.json" @@ -123,6 +125,9 @@ jobs: - name: Test wrapper mapping run: godot --headless --path Example --script res://tests/test_godot_iap.gd + - name: Test export store mapping + run: godot --headless --path Example --script res://tests/test_android_store.gd + swift-test: name: Swift Test # SwiftGodot v0.79.0 declares a Swift 6.3 package manifest. Keep this diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index 10ecca072..8c29628b3 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -89,7 +89,7 @@ jobs: android-binding: name: Android binding (net10.0-android) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 permissions: contents: read steps: @@ -112,11 +112,7 @@ jobs: - name: Install MAUI workload run: dotnet workload install maui-android --skip-sign-check - # The Android binding consumes the selected Google AAR as an unbound - # runtime dependency, plus the MAUI-owned OpenIapMauiModule AAR as the - # bound facade. Build all Google store artifacts up front, then rebuild - # the MAUI facade/binding/library for each store because the facade AAR - # output path is shared across store selections. + # The app build links one store's AAR, so build all three for the suite below. - name: Build Google AARs working-directory: packages/google run: | @@ -125,7 +121,7 @@ jobs: :openiap:assembleAmazonRelease \ :openiap:assembleHorizonRelease - # `-p:TargetFrameworks=net10.0-android` (PLURAL) isolates each store build. + # `-p:TargetFrameworks=net10.0-android` (PLURAL) isolates the Android build. # The singular `-f net10.0-android` filters the inner # build but leaves the implicit restore walking ALL 4 TFMs in the # project's multi-target list. That makes restore load the @@ -135,22 +131,25 @@ jobs: working-directory: libraries/maui-iap run: | DOTNET_BUILD_ARGS=(/m:1 /nr:false -p:UseSharedCompilation=false --nologo) - for store in play amazon horizon; do - printf '\n== Building MAUI Android store: %s (net10.0) ==\n' "$store" - dotnet build-server shutdown || true - rm -rf \ - src/OpenIap.Maui.Bindings.Android/bin \ - src/OpenIap.Maui.Bindings.Android/obj - ( - cd android + ( + cd android + # One facade AAR serves every store, so it must compile against each + # store's openiap-google API, not only the Play one it ships with. + for store in horizon amazon; do "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" \ - ../../../packages/google/gradlew \ - :openiap:assembleRelease \ - -PopenIapAndroidStore="$store" - ) - dotnet build src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj -p:TargetFrameworks=net10.0-android -p:OpenIapAndroidStore="$store" "${DOTNET_BUILD_ARGS[@]}" - dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -p:TargetFrameworks=net10.0-android -p:OpenIapAndroidStore="$store" -p:BuildProjectReferences=false "${DOTNET_BUILD_ARGS[@]}" - done + ../../../packages/google/gradlew :openiap:compileReleaseKotlin -PopeniapStore="$store" + done + "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" \ + ../../../packages/google/gradlew :openiap:assembleRelease + ) + dotnet build src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj -p:TargetFrameworks=net10.0-android "${DOTNET_BUILD_ARGS[@]}" + dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -p:TargetFrameworks=net10.0-android -p:BuildProjectReferences=false "${DOTNET_BUILD_ARGS[@]}" + + # A wrong store is invisible until the app reaches a device, so the rule + # is asserted here against a fake adb. + - name: Verify the store selection + working-directory: libraries/maui-iap + run: bash scripts/verify-store-selection.sh --apk app-store-artifact: name: App Store artifact (Xcode 26.6) diff --git a/.github/workflows/ci-react-native-iap.yml b/.github/workflows/ci-react-native-iap.yml index bf4dc2425..cb36bbe2b 100644 --- a/.github/workflows/ci-react-native-iap.yml +++ b/.github/workflows/ci-react-native-iap.yml @@ -9,10 +9,12 @@ on: - "libraries/expo-iap/package.json" - "libraries/expo-iap/bun.lock" - "scripts/test-android-gradle-compatibility.mjs" + - "scripts/ci/retry-gradle.sh" - "specs/client/src/generated/types.ts" - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" - "scripts/test-ios-log-sanitizers.sh" @@ -26,10 +28,12 @@ on: - "libraries/expo-iap/package.json" - "libraries/expo-iap/bun.lock" - "scripts/test-android-gradle-compatibility.mjs" + - "scripts/ci/retry-gradle.sh" - "specs/client/src/generated/types.ts" - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" - "scripts/test-ios-log-sanitizers.sh" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5fac44c7..a6b257730 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,7 +302,7 @@ jobs: - name: The doctor exits 1 on a project it proves is broken run: | mkdir -p /tmp/doctor-fixture/android/app - printf 'horizonEnabled=false\nfireOsEnabled=false\n' \ + printf 'openiapStore=play\n' \ > /tmp/doctor-fixture/android/gradle.properties printf 'missingDimensionStrategy "platform", "horizon"\n' \ > /tmp/doctor-fixture/android/app/build.gradle @@ -582,6 +582,17 @@ jobs: working-directory: packages/google run: bash scripts/verify-kotlin-2.1-consumer.sh + # A wrong store is invisible until the artifact reaches a device, so the + # selection rule is asserted here rather than on hardware. + - name: Verify the store resolver + working-directory: packages/google + run: bash scripts/verify-store-resolver.sh + + # The plugin carries that rule to apps linking the published artifacts. + - name: Verify the store plugin + working-directory: packages/google + run: bash scripts/verify-store-plugin.sh + # Run every store flavor so flavor-specific API-23 regressions cannot # bypass lint coverage. - name: Lint Android API compatibility diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c3078fe32..e9a1a9736 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -303,7 +303,7 @@ jobs: "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" \ ../../../packages/google/gradlew \ --no-daemon --no-build-cache --rerun-tasks \ - :openiap:assembleRelease -PopenIapAndroidStore=play + :openiap:assembleRelease -PopeniapStore=play - name: Analyze uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 diff --git a/.github/workflows/release-expo.yml b/.github/workflows/release-expo.yml index 327f0374a..e8a2263cd 100644 --- a/.github/workflows/release-expo.yml +++ b/.github/workflows/release-expo.yml @@ -407,7 +407,7 @@ jobs: fi PREV_TAG=$(git for-each-ref --sort=-creatordate --format '%(refname:short)' 'refs/tags/expo-iap-*' | grep -v "$VERSION" | head -n 1) if [ -n "$PREV_TAG" ]; then - CHANGELOG=$(git log "$PREV_TAG..$RELEASE_REF" --invert-grep --grep='^chore(release):' --pretty=format:"- %s ([\`%h\`](https://github.com/hyodotdev/openiap/commit/%H))" -- ":(top)libraries/expo-iap/" ":(top,exclude)libraries/expo-iap/.claude/" ":(top,exclude)libraries/expo-iap/AGENTS.md" ":(top,exclude)libraries/expo-iap/CLAUDE.md") + CHANGELOG=$(git log "$PREV_TAG..$RELEASE_REF" --invert-grep --grep='^chore(release):' --pretty=format:"- %s ([\`%h\`](https://github.com/hyodotdev/openiap/commit/%H))" -- ":(top)libraries/expo-iap/" ":(top)packages/google/gradle/openiap-store.gradle" ":(top,exclude)libraries/expo-iap/.claude/" ":(top,exclude)libraries/expo-iap/AGENTS.md" ":(top,exclude)libraries/expo-iap/CLAUDE.md") fi if [ -z "$CHANGELOG" ]; then CHANGELOG="- No direct code changes — picks up the latest openiap-google / openiap-apple native library updates. See the consolidated release notes for details." @@ -617,10 +617,12 @@ jobs: - name: Resolve symlinks for npm publish run: | - # npm ignores symlinks — replace with real file - if [ -L openiap-versions.json ]; then - cp --remove-destination "$(readlink openiap-versions.json)" openiap-versions.json - fi + # npm drops symlinks, so publish the linked files as copies. + for link in openiap-versions.json android/openiap-store.gradle; do + if [ -L "$link" ]; then + cp --remove-destination "$(readlink -f "$link")" "$link" + fi + done - name: Consumer install smoke test run: bun run verify:consumer-install --pack-ignore-scripts diff --git a/.github/workflows/release-flutter.yml b/.github/workflows/release-flutter.yml index dd03db180..59f2073b4 100644 --- a/.github/workflows/release-flutter.yml +++ b/.github/workflows/release-flutter.yml @@ -521,7 +521,7 @@ jobs: fi PREV_TAG=$(git for-each-ref --sort=-creatordate --format '%(refname:short)' 'refs/tags/flutter-iap-*' | grep -v "$VERSION" | head -n 1) if [ -n "$PREV_TAG" ]; then - CHANGELOG=$(git log "$PREV_TAG..$RELEASE_REF" --invert-grep --grep='^chore(release):' --pretty=format:"- %s ([\`%h\`](https://github.com/hyodotdev/openiap/commit/%H))" -- ":(top)libraries/flutter_inapp_purchase/" ":(top,exclude)libraries/flutter_inapp_purchase/.claude/" ":(top,exclude)libraries/flutter_inapp_purchase/AGENTS.md" ":(top,exclude)libraries/flutter_inapp_purchase/CLAUDE.md") + CHANGELOG=$(git log "$PREV_TAG..$RELEASE_REF" --invert-grep --grep='^chore(release):' --pretty=format:"- %s ([\`%h\`](https://github.com/hyodotdev/openiap/commit/%H))" -- ":(top)libraries/flutter_inapp_purchase/" ":(top)packages/google/gradle/openiap-store.gradle" ":(top,exclude)libraries/flutter_inapp_purchase/.claude/" ":(top,exclude)libraries/flutter_inapp_purchase/AGENTS.md" ":(top,exclude)libraries/flutter_inapp_purchase/CLAUDE.md") fi if [ -z "$CHANGELOG" ]; then CHANGELOG="- No direct code changes — picks up the latest openiap-google / openiap-apple native library updates. See the consolidated release notes for details." diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml index 754d271c2..ae39111ee 100644 --- a/.github/workflows/release-google.yml +++ b/.github/workflows/release-google.yml @@ -262,11 +262,41 @@ jobs: ;; esac + - name: Check if Gradle plugin already published + id: check_plugin + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + # plugins { id(...) } resolves the marker, so the plugin counts as + # published only when both it and its jar's POM are there. + BASE="https://repo1.maven.org/maven2/io/github/hyochan/openiap" + pom_status() { curl -sS -o /dev/null -w "%{http_code}" "$BASE/$1/$VERSION/$1-$VERSION.pom" || true; } + STATUS="$(pom_status openiap-gradle-plugin)/$(pom_status io.github.hyochan.openiap.gradle.plugin)" + case "$STATUS" in + 200/200) + echo "exists=true" >> $GITHUB_OUTPUT + echo "⚠️ openiap-gradle-plugin $VERSION and its marker already exist on Maven Central" + ;; + 404/404) + echo "exists=false" >> $GITHUB_OUTPUT + echo "✓ openiap-gradle-plugin $VERSION does not exist, will publish" + ;; + 200/404|404/200) + echo "❌ openiap-gradle-plugin $VERSION is only partly on Maven Central (plugin/marker: $STATUS); release a new version" + exit 1 + ;; + *) + echo "❌ Unable to verify openiap-gradle-plugin $VERSION on Maven Central (plugin/marker: HTTP $STATUS)" + exit 1 + ;; + esac + - name: Preflight Maven Central publication env: HORIZON_EXISTS: ${{ steps.check_horizon.outputs.exists }} AMAZON_EXISTS: ${{ steps.check_amazon.outputs.exists }} PLAY_EXISTS: ${{ steps.check_play.outputs.exists }} + PLUGIN_EXISTS: ${{ steps.check_plugin.outputs.exists }} RELEASE_TAG_EXISTS: ${{ steps.check_tag.outputs.exists }} MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} @@ -277,13 +307,15 @@ jobs: if [ "$RELEASE_TAG_EXISTS" != "true" ] && \ { [ "$HORIZON_EXISTS" = "true" ] || \ [ "$AMAZON_EXISTS" = "true" ] || \ - [ "$PLAY_EXISTS" = "true" ]; }; then - echo "::error::A Google flavor is already published without a release tag; release a new version instead" + [ "$PLAY_EXISTS" = "true" ] || \ + [ "$PLUGIN_EXISTS" = "true" ]; }; then + echo "::error::A Google artifact is already published without a release tag; release a new version instead" exit 1 fi if [ "$HORIZON_EXISTS" = "false" ] || \ [ "$AMAZON_EXISTS" = "false" ] || \ - [ "$PLAY_EXISTS" = "false" ]; then + [ "$PLAY_EXISTS" = "false" ] || \ + [ "$PLUGIN_EXISTS" = "false" ]; then for credential in \ "$MAVEN_CENTRAL_USERNAME" "$MAVEN_CENTRAL_PASSWORD" \ "$GPG_KEY_CONTENTS" "$SIGNING_KEY_ID" "$SIGNING_PASSWORD"; do @@ -363,6 +395,12 @@ jobs: working-directory: packages/google run: ./gradlew :openiap:assembleRelease --no-daemon --stacktrace + - name: Build Gradle plugin + working-directory: packages/google/gradle-plugin + env: + ORG_GRADLE_PROJECT_openIapVersion: ${{ steps.version.outputs.version }} + run: ../gradlew build --no-daemon --stacktrace + - name: Create release artifacts working-directory: packages/google run: | @@ -471,6 +509,31 @@ jobs: } \`\`\` + #### Pick the store automatically + + Apply the plugin in settings.gradle.kts and depend on openiap-google: a + connected Quest or Fire device selects its store on a debug build, and + \`openiapStore\` pins release builds. + + \`\`\`kotlin + // settings.gradle.kts: the plugin is on Maven Central, not the Gradle Plugin Portal + pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + } + plugins { + id("io.github.hyochan.openiap") version "$VERSION" + } + + // app/build.gradle.kts + dependencies { + implementation("io.github.hyochan.openiap:openiap-google:$VERSION") + } + \`\`\` + ### Documentation - [API reference](https://openiap.dev/docs/apis) @@ -482,6 +545,7 @@ jobs: - [openiap-google (Play)](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google/$VERSION) - [openiap-google-horizon (Horizon)](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google-horizon/$VERSION) - [openiap-google-amazon (Fire OS)](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google-amazon/$VERSION) + - [openiap-gradle-plugin](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-gradle-plugin/$VERSION) EOF - name: Create and push tag @@ -545,6 +609,20 @@ jobs: ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace echo "✅ Published openiap-google (Play flavor) to Maven Central" + - name: Publish Gradle plugin to Maven Central + if: steps.check_plugin.outputs.exists == 'false' + working-directory: packages/google/gradle-plugin + env: + ORG_GRADLE_PROJECT_openIapVersion: ${{ steps.version.outputs.version }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_KEY_CONTENTS }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} + run: | + ../gradlew publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace + echo "✅ Published openiap-gradle-plugin to Maven Central" + - name: Create GitHub Release working-directory: packages/google env: diff --git a/.github/workflows/release-maui.yml b/.github/workflows/release-maui.yml index dd3a3074b..f043b4153 100644 --- a/.github/workflows/release-maui.yml +++ b/.github/workflows/release-maui.yml @@ -119,9 +119,10 @@ jobs: - name: Build OpenIAP.xcframework (Apple) run: bash packages/apple/scripts/build-xcframework.sh - - name: Build Play AAR (Google) + # The package carries every store's AAR; the app build links one. + - name: Build store AARs (Google) working-directory: packages/google - run: ./gradlew :openiap:assemblePlayRelease + run: ./gradlew :openiap:assemblePlayRelease :openiap:assembleHorizonRelease :openiap:assembleAmazonRelease - name: Build MAUI Android module AAR working-directory: libraries/maui-iap/android @@ -331,9 +332,10 @@ jobs: - name: Verify App Store toolchain provenance run: bash packages/apple/scripts/verify-app-store-xcframework.sh - - name: Build Play AAR (Google) + # The package carries every store's AAR; the app build links one. + - name: Build store AARs (Google) working-directory: packages/google - run: ./gradlew :openiap:assemblePlayRelease + run: ./gradlew :openiap:assemblePlayRelease :openiap:assembleHorizonRelease :openiap:assembleAmazonRelease - name: Build MAUI Android module AAR working-directory: libraries/maui-iap/android @@ -347,6 +349,22 @@ jobs: -c Release \ -p:Version=$VERSION \ -o ./nupkgs + # The app build picks one store AAR from android/; one in lib/ would + # link into every build. + CONTENTS=$(unzip -l ./nupkgs/OpenIap.Maui.*.nupkg | awk '{print $4}') + for entry in \ + android/openiap-play-release.aar \ + android/openiap-horizon-release.aar \ + android/openiap-amazon-release.aar \ + buildTransitive/OpenIap.Maui.props \ + buildTransitive/OpenIap.Maui.targets \ + lib/net10.0-android36.0/openiap-release.aar; do + printf '%s\n' "$CONTENTS" | grep -qx "$entry" || { echo "::error::$entry is missing from the package"; exit 1; } + done + if printf '%s\n' "$CONTENTS" | grep -E '^lib/.*openiap-(play|horizon|amazon)-release\.aar$'; then + echo "::error::a store AAR is in lib/, where every build would link it" + exit 1 + fi - name: Check if NuGet package already published id: check_nuget diff --git a/.github/workflows/release-react-native.yml b/.github/workflows/release-react-native.yml index bb0964434..dbd4fa517 100644 --- a/.github/workflows/release-react-native.yml +++ b/.github/workflows/release-react-native.yml @@ -416,7 +416,7 @@ jobs: fi PREV_TAG=$(git for-each-ref --sort=-creatordate --format '%(refname:short)' 'refs/tags/react-native-iap-*' | grep -v "$VERSION" | head -n 1) if [ -n "$PREV_TAG" ]; then - CHANGELOG=$(git log "$PREV_TAG..$RELEASE_REF" --invert-grep --grep='^chore(release):' --pretty=format:"- %s ([\`%h\`](https://github.com/hyodotdev/openiap/commit/%H))" -- ":(top)libraries/react-native-iap/" ":(top,exclude)libraries/react-native-iap/.claude/" ":(top,exclude)libraries/react-native-iap/AGENTS.md" ":(top,exclude)libraries/react-native-iap/CLAUDE.md") + CHANGELOG=$(git log "$PREV_TAG..$RELEASE_REF" --invert-grep --grep='^chore(release):' --pretty=format:"- %s ([\`%h\`](https://github.com/hyodotdev/openiap/commit/%H))" -- ":(top)libraries/react-native-iap/" ":(top)packages/google/gradle/openiap-store.gradle" ":(top,exclude)libraries/react-native-iap/.claude/" ":(top,exclude)libraries/react-native-iap/AGENTS.md" ":(top,exclude)libraries/react-native-iap/CLAUDE.md") fi if [ -z "$CHANGELOG" ]; then CHANGELOG="- No direct code changes — picks up the latest openiap-google / openiap-apple native library updates. See the consolidated release notes for details." @@ -627,9 +627,12 @@ jobs: - name: Resolve symlinks for npm publish run: | - if [ -L openiap-versions.json ]; then - cp --remove-destination "$(readlink openiap-versions.json)" openiap-versions.json - fi + # npm drops symlinks, so publish the linked files as copies. + for link in openiap-versions.json android/openiap-store.gradle; do + if [ -L "$link" ]; then + cp --remove-destination "$(readlink -f "$link")" "$link" + fi + done - name: Consumer install smoke test run: node .yarn/releases/yarn-3.6.1.cjs verify:consumer-install --pack-ignore-scripts diff --git a/.gitignore b/.gitignore index 37996b6fe..da86d6a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,5 @@ libraries/*/docs/build/ # Resx generated designer code (per-build artifact) *.resx.designer.cs knowledge/research/_datasets/ +# Device E2E run reports (maestro-runner timestamped output, local only) +/reports/ diff --git a/.mcp.json b/.mcp.json index 5502593b4..2f9407a56 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,10 +1,13 @@ { "mcpServers": { "openiap": { - "type": "http", - "url": "https://kit.openiap.dev/mcp", - "headers": { - "Authorization": "Bearer ${IAPKIT_API_KEY:-}" + "command": "sh", + "args": [ + "-c", + "exec npx -y supergateway --streamableHttp https://kit.openiap.dev/mcp --header \"Authorization: Bearer $IAPKIT_API_KEY\"" + ], + "env": { + "IAPKIT_API_KEY": "${IAPKIT_API_KEY:-}" } } } diff --git a/AGENTS.md b/AGENTS.md index e67504e7d..0b3721609 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,14 @@ KISS and SSOT are mandatory release criteria. The canonical rules live in [`knowledge/internal/03-coding-style.md`](knowledge/internal/03-coding-style.md#0-kiss-and-ssot-are-release-requirements). Apply that section before implementation and during every review. +### Clean Up Once It Works + +Once a change works, reread the diff for code smells — duplication, redundant +fallbacks, `as any`, stale comments — and fix them without being asked, +including ones you run into in code the change does not touch. Never write +`as any`. The checklist is in +[`knowledge/internal/03-coding-style.md`](knowledge/internal/03-coding-style.md#clean-up-once-it-works). + ### Repository Layout Treat the directory ownership rules in @@ -96,11 +104,14 @@ directory, and run `bun run audit:layout` after adding or moving directories. ### Comment Style -Keep comments short — default to one line. AI-authored comments over-explain by -default, so trim before committing: no restating the code, no narrating the -change or its history (that belongs in the commit message), no explaining -well-known APIs. Keep only what the code cannot show: platform quirks, non-obvious -constraints, and why an obvious alternative was rejected. Full checklist in +Keep comments short — default to one line — and write them for a person reading +the code cold: the point first, plain words, one idea. AI-authored comments +over-explain by default, so trim before committing: no restating the code, no +narrating the change or its history (that belongs in the commit message), no +explaining well-known APIs. Keep only what the code cannot show: platform +quirks, non-obvious constraints, and why an obvious alternative was rejected. +Trim a verbose comment wherever you meet one, not only in your own change. Full +checklist in [`knowledge/internal/03-coding-style.md`](knowledge/internal/03-coding-style.md#keep-them-short--especially-ai-generated-ones). ### Reader-First Documentation @@ -366,6 +377,9 @@ Cursor-specific files. | `$opencollective-steward` | Manage OpenCollective profile and updates | `$opencollective-steward` | | `$iapkit-e2e-petgu` | IAPKit product-sync E2E with the Petgu app | `$iapkit-e2e-petgu` | | `$iapkit-e2e-martie` | IAPKit local receipt-validation E2E with Martie | `$iapkit-e2e-martie` | +| `$e2e-matrix-runner` | Full device matrix: 6 frameworks x iOS/Play/Amazon/Horizon/Vega | `$e2e-matrix-runner` | +| `$e2e-matrix-runner-google` | Android half: 6 frameworks x Play/Amazon/Horizon + Vega | `$e2e-matrix-runner-google` | +| `$e2e-matrix-runner-apple` | Apple half: 6 frameworks x iOS + Onside build-only | `$e2e-matrix-runner-apple` | | `/review-pr` | Review PR comments, fix issues, resolve threads | `/review-pr 65` or `/review-pr ` | | `/audit-code` | Audit code against knowledge rules and latest APIs | `/audit-code` | | `/audit-security` | Audit SBOM, provenance, and supply-chain posture | `/audit-security` | @@ -374,6 +388,8 @@ Cursor-specific files. | `/resolve-issue` | Analyze an issue, label it, and fix/comment | `/resolve-issue 88` | | `/verify-all` | Run the full monorepo health check | `/verify-all` | | `/e2e-tests` | Run device-backed OpenIAP regression tests | `/e2e-tests PR 162` | +| `/e2e-tests-google` | Run Android-side device regression (Play/Amazon/Horizon/Vega) | `/e2e-tests-google` | +| `/e2e-tests-apple` | Run Apple-side device regression (iOS) | `/e2e-tests-apple` | | `/release` | Release stable packages or an on-demand RC train | `/release all stable` | | `/commit` | Branch, commit, push, and optionally create PR | `/commit --all --pr` | diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md index 3cd5297f6..17223f645 100644 --- a/knowledge/_agent-context/context.md +++ b/knowledge/_agent-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated shared context for AI assistants** -> Last updated: 2026-09-18T18:47:47.947Z +> Last updated: 2026-09-24T20:51:39.505Z > > Canonical file: `knowledge/_agent-context/context.md` @@ -666,6 +666,25 @@ contracts; it requires meeting them with the fewest independent concepts. consolidated. Prefer one understandable path over several defensive fallback paths. +#### Clean Up Once It Works + +A change is not done when it first passes. Reread the diff and the code it +touches for smells, and fix them without being asked. A smell you run into +while working counts too, even in code the change does not touch: + +- duplicated logic, parallel branches that compute one decision, and helpers + copied between files; +- layers, fallbacks, or checks that another part of the system already + guarantees; +- `as any`, which is never allowed. Type the value or narrow it; in tests, use + typed fixtures and `jest.mocked`, and mark input a test passes on purpose to + reach a runtime check with `// @ts-expect-error` and the reason. + `as unknown as T` hides the same problem; +- comments the code now contradicts, comments longer than their point, and + dead or unreachable code (see "Write for a Human Reading It Cold"). + +Rerun the checks afterwards: a cleanup that changes behavior is a bug. + ### 1. Explicit Over Implicit Always be explicit about types and intentions: @@ -941,6 +960,38 @@ isActive = purchaseState == PurchaseState.Purchased Section banners (`// --- Runner ---`) are fine when a file has genuinely distinct parts; do not add them to short files. +### Write for a Human Reading It Cold + +A comment is read by someone who did not write the code and has one question. +Answer it in plain words they can take in at a glance. + +- Lead with the point. Put the constraint or the reason first, not a setup. +- One idea per comment, in short sentences. If it needs "because ... so ... + which means ...", split it or cut it. +- Use ordinary words and name concrete things: the store, the task, the file. + Avoid abstract phrasing such as "a signal that disagrees stops the build" + when "an openiapStore pin and a Horizon task fail the build" says it. +- Say it once. A rule explained in the file header is not re-explained at each + use; the use can say nothing, or point at the header. +- Leave out how the code got here: review rounds, earlier bugs, "once", "twice", + "used to". That history is in git. + +These are the habits that make a comment read like AI prose. Remove them on +sight, in the code you change and in the code you pass through. + +```groovy +// ❌ INCORRECT — an essay: history, hedges, and three ideas in one block +// Gradle task options that take a separate value, derived from `help --task` +// over `tasks --all` under a Gradle 8 and a Gradle 9, because each major has +// tasks the other does not ... Anything unlisted is treated as a flag, so a +// flag never eats the task after it ... The cost runs the other way ... + +// ✅ CORRECT — what the list is, and what happens when it is incomplete +// Task options that take a separate value, from Gradle's `help --task` output. +// An unlisted option's value may be read as a task; the graph check then fails +// unless the value also names a task the build runs. +``` + ### Doc Comments Are Not the Docs Site A public API's doc comment states the **contract**; `packages/docs` states the @@ -1240,7 +1291,7 @@ For every new/changed handler in the generated types, verify **all five** of the | **expo-iap** | `src/types.ts` (generated) | `src/modules/ios.ts` / `android.ts` export, re-exported from `src/index.ts` | `ios/ExpoIapModule.swift` `AsyncFunction`, `android/.../ExpoIapModule.kt` | Not required (flat exports) | `src/modules/__tests__/*.test.ts` | | **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift` and `macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | | **kmp-iap** | `library/src/commonMain/.../openiap/Types.kt` (generated interface) | exposed via `KmpInAppPurchase` / `kmpIapInstance` | `library/src/iosMain/.../InAppPurchaseIOS.kt` — must call `openIapModule.WithCompletion { ... }`, **never** `throw UnsupportedOperationException` | Not required (interface dispatch) | `library/src/commonTest/` if testable cross-platform | -| **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | `make test` covers generated types, API surface, native-extension loading, envelope parsing, and public GDScript behavior; physical devices remain required for store purchases | +| **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | `make test` covers generated types, API surface, native-extension loading, envelope parsing, and public GDScript behavior; physical devices remain required for store purchases | | **maui-iap** | `src/OpenIap.Maui/Types.cs` (generated) | `OpenIap.QueryResolver` / `MutationResolver` interfaces in `Types.cs`; `IOpenIap` adds the native purchase-listener contract; static facade is `OpenIap.Maui.OpenIapClient`; app-facing IAPKit helpers are exposed via `OpenIapClient.KitApi(...)` | Android: `OpenIapMauiModule.kt` in `libraries/maui-iap/android/openiap/` (JSON-shaped Java facade over `packages/google`), bound by `OpenIap.Maui.Bindings.Android.csproj`, consumed by `Platforms/Android/OpenIapAndroid.cs`. Google Billing / Play Services / Gson / AndroidX / Kotlin dependencies must stay NuGet `PackageReference`s, not fat-bundled AARs. iOS / macCatalyst: existing `OpenIapModule+ObjC.swift` bridge in `packages/apple`, bound by hand-written `OpenIap.Maui.Bindings.iOS/ApiDefinition.cs`, consumed by `Platforms/iOS/OpenIapIOS.cs` (+ subclass `OpenIapMacCatalyst`). | Not required (interface dispatch) | OpenIap.Maui 2.x targets supported .NET 10 only. The example app `libraries/maui-iap/example/OpenIap.Maui.Example` builds for net10.0-android / net10.0-ios / net10.0-maccatalyst; package CI builds net10 shared, Android, iOS, and macCatalyst TFMs; xUnit covers generated serialization, error mapping, and the `KitApiClient` HTTP contract (manual device testing remains for purchase flow) | ### Platform suffix rule (who needs what) @@ -1330,6 +1381,103 @@ The Google package supports **three build flavors**: - `src/horizon/` - Meta Horizon specific implementations - `src/amazon/` - Amazon Appstore specific implementations +### Store Selection + +One rule picks the Android store everywhere, and the developer never edits a +file to switch. Credentials (the Horizon app id, the Amazon +`AppstoreAuthenticationKey.pem`) stay in the project permanently and are inert +on the other stores; they never select anything. + +```text +1. explicit openiapStore= -P / ORG_GRADLE_PROJECT_openiapStore / gradle.properties + (legacy horizonEnabled, fireOsEnabled, openiapPlatform=none: still read, deprecation warning) +2. variant a requested task carries a store flavor: assembleHorizonRelease, installAmazonDebug +3. device debug tasks only: the adb device ANDROID_SERIAL names, or the single + attached one -> Quest = horizon, Fire = amazon +4. play +``` + +A store pin against a different task flavor, two store flavors named by the +requested tasks, and a pin against a legacy flag each fail the build. Opting out +with `openiapStore=none` never conflicts with a task flavor, because it links +nothing; it does still conflict with a legacy flag that names a store. An anchor +task that +builds every flavor — `assemble`, or `assembleDebug` reaching a source-included +openiap-google — is not that case and is allowed. The device is a fallback, not a +competing signal — a pin or a flavor outranks it without complaint. A release +build never consults a device, and several attached devices select nothing +unless `ANDROID_SERIAL` names one. The device step works under the +configuration cache: Gradle re-runs the probe before reusing a cached +configuration, so a different device reconfigures the build. The choice is +logged once: +`openiap: store= (source=explicit|variant|device|default; )`. + +**Vocabulary.** Store ids are `play`, `horizon`, `amazon`, plus `auto` (the +default) and `none` (the Flutter opt-out that links no Android IAP SDK). Aliases +are normalized at the input boundary only: `google`, `gplay`, `googleplay`, +`google-play`, `gms` → `play`; `meta`, `quest` → `horizon`; `fire`, `fireos`, +`fire-os` → `amazon`. `IapStore` in the schema is the _runtime_ store on a +purchase and keeps its own names. + +**SSOT.** `packages/google/gradle/openiap-store.gradle` implements the rule; +edit only that file. A Gradle script cannot ship in the AAR, so +`libraries/react-native-iap/android`, `libraries/expo-iap/android`, and +`libraries/flutter_inapp_purchase/android` symlink it, as the libraries do with +`openiap-versions.json`, and `bun audit:parity` checks the link targets. Each +wrapper publishes it as a real file: the npm release steps copy it over the +link, and `dart pub publish` follows the link. The OpenIAP Gradle plugin +(`packages/google/gradle-plugin`, id `io.github.hyochan.openiap`) packs the same +file into its jar at build time. Every other build system reads the same names: + +| Consumer | Input | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------- | +| react-native-iap, expo-iap, Flutter | wrapper `build.gradle` applies the script; example apps do the same | +| expo-iap config plugin | writes no store; deprecated `modules.horizon` / `modules.amazon.fireOS` still pin, with a warning | +| kmp-iap | library flavors match an app `platform` dimension, or the Gradle plugin picks one | +| OpenIAP Gradle plugin (native, KMP) | applied in settings; selects kmp-iap's store variant and swaps `openiap-google` for the store | +| maui-iap | package targets at app build: `OpenIapStore` (alias `OpenIapAndroidStore`), Debug-build device, play | +| godot-iap | export option `openiap/android_store`; `auto` follows the device on a debug export, else play | +| `openiap doctor` | reads `openiapStore`, `openiapPlatform` and the legacy flags with the same table | + +`bun audit:parity` compares all five alias tables — the resolver, the doctor, +the Godot helper, the runtime facade in `OpenIapStore.kt` and the MAUI package +targets — because a store that resolves differently in two layers of one build +is exactly what this mechanism exists to prevent. + +**Regression suite.** Every rule above is asserted by +`packages/google/scripts/verify-store-resolver.sh`, which CI runs in the Test +Android job. It covers abbreviated task names such as `aHR`, which once let +a Horizon build link the Play SDK and now fail at the task-graph check: + +```bash +cd packages/google && bash scripts/verify-store-resolver.sh +``` + +`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved +store reaches the published `openiap-google` and `kmp-iap` artifacts in an app, +a KMP library module, and a module with its own `platform` flavors (which the +plugin leaves alone). It needs an Android SDK and the network. + +It applies the real resolver to the fixture in +`packages/google/compatibility/store-resolver`, so no Android SDK, device, or +network is needed; `compatibility/store-resolver/fake-adb` stands in for adb and +reports whatever device the case declares. Each case asserts a resolved +`store/source` pair, or that the build fails with a named message. The suite +covers pins and their aliases, the legacy flags and their conflicts, the +`none` opt-out, task flavors, every conflict that must fail, device selection +for Quest, Fire and everything else, `ANDROID_SERIAL`, several attached +devices, release builds, `clean`, and the configuration cache. + +**Add a case whenever the rule changes.** A wrong store is invisible on the +machine that built it — it only appears when the artifact reaches a device that +cannot serve that billing SDK, which is after release. The suite is the only +thing standing between a rule change and that outcome, so a new signal, alias, +or conflict lands with its case in the same commit. + +**iOS.** There is one store axis (App Store vs. an alternative marketplace such +as Onside). Marketplace SDKs are linked at build time by an explicit opt-in and +the runtime routes by install source; nothing is guessed at build time. + ### Critical Rules 1. **DO NOT edit generated files**: `openiap/src/main/java/dev/hyo/openiap/Types.kt` is auto-generated @@ -2160,8 +2308,8 @@ the `next` branch. Preserve the change evidence, then write one concise, package-grouped entry after stable promotion on `main`. Do not use `openiap-versions.json` to derive React Native, Expo, Flutter, -Godot, KMP, or MAUI versions; that manifest tracks only `spec`, `google`, and -`apple`. +Godot, KMP, or MAUI versions; that manifest tracks only `clientProtocol`, +`google`, and `apple`. --- @@ -4610,8 +4758,8 @@ This document provides external API reference for Apple's StoreKit 2 framework. | `Product.SubscriptionInfo.RenewalInfo.eligibleWinBackOfferIDs` | iOS 18.0 | Query win-back offer eligibility before purchase | | Consumable transaction history | iOS 18.0 | Opt-in via `SKIncludeConsumableInAppPurchaseHistory` Info.plist key | | StoreKit `Message.billingIssue` | iOS / Mac Catalyst 16.4, visionOS 1.0 | Listener for subscription billing issues (`Message` is unavailable on macOS, tvOS, and watchOS) | -| UI context for purchases | iOS 18.2 | Required for proper payment sheet display | -| External purchase notice | iOS 17.4 | `ExternalPurchase.presentNoticeSheet()` | +| UI context for purchases | iOS 17.0 | `purchase(confirmIn:)` takes a `UIScene`; iOS 18.2 adds `UIViewController`, macOS 15.2 `NSWindow` | +| External purchase notice token | iOS 17.4 | `ExternalPurchase.canPresent`; `presentNoticeSheet()` returns a token | | `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) | | `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) | | `Transaction.offerPeriod` | iOS 18.4 | Offer period information on Transaction | @@ -4960,9 +5108,11 @@ if renewalInfo.renewalOfferType == .winBack { } ``` -## UI Context for Purchases (iOS 18.2+) +## UI Context for Purchases -Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets: +`purchase(confirmIn:)` takes a `UIScene` from iOS 17.0; iOS 18.2 adds a +`UIViewController` overload and macOS 15.2 an `NSWindow` one. Apple recommends a UI-context purchase API over +`purchase(options:)` everywhere except watchOS: ```swift // iOS/iPadOS/tvOS/visionOS: UIViewController @@ -5141,10 +5291,11 @@ By default, `Transaction.all` omits finished consumables. Opt in by adding this With the key set, finished consumable transactions are included in `Transaction.all`, `Transaction.latest(for:)`, and `Product.latestTransaction`. -## External Purchase Support (iOS 17.4+) +## External Purchase Support -`ExternalPurchase.presentNoticeSheet()` / `ExternalPurchaseLink.open(url:)` -ship on iOS 17.4+. The follow-on custom-link APIs +`ExternalPurchase.presentNoticeSheet()` ships on iOS 15.4. `canPresent` and the +sheet's `continuedWithExternalPurchaseToken(token:)` result arrive in iOS 17.4. +`ExternalPurchaseLink.open(url:)` is iOS 17.5+. The custom-link APIs (`ExternalPurchaseCustomLink.isEligible`, `showNotice(type:)`, `token(for:)`) are iOS 18.1+. @@ -5414,7 +5565,9 @@ executable projection validators consume. | `generated/vectors/lifecycle.json` | Generated entitlement, first-binding, and event-emission vectors implementations reproduce | | `generated/bindings/http-binding.json` | Generated HTTP manifest: method, path, auth role, statuses, and schema pointer per operation | | `generated/bindings/operations.graphql` | Generated executable GraphQL projection the GraphQL binding serves | +| `generated/bindings/operations-sdl.json` | Generated JSON wrapper of `operations.graphql` for bundlers; the SDL is byte-identical | | `generated/bindings/graphql-operations.json` | Generated canonical full-selection GraphQL documents | +| `generated/bindings/introspection-signature.json` | Generated structural signature the runner checks served introspection against (§11.3) | | `generated/openapi/commerce-protocol.openapi.json` | Generated OpenAPI 3.1 document for the REST binding | | `generated/vectors/operations.json` | Generated operation conformance vectors | | `conformance/` | The portable conformance runner and its independent mock provider | @@ -5494,11 +5647,18 @@ consumer doing revenue reconciliation SHOULD accept only `store`. **Identifiers** are opaque strings. A consumer MUST NOT parse structure out of one. -**Enumerations** are closed unless this document says otherwise. Four value -spaces are deliberately open — `environment` here, `store` below, -`cancellationReason` on the subscription snapshot, and `eventType`, which §12 -grows in a MINOR version — and a consumer MUST tolerate a value it does not -recognise in any of them, and MUST NOT act on one it does not know. +**Enumerations** are closed unless this document says otherwise. These value +spaces are open, and a MINOR version can add a value to any of them (§12): + +- `store` (below) and `environment` +- `cancellationReason` (§2.2) +- `eventType` (§9.1) +- the verification `state` (§4.1) and the erasure job `status` (§4.5) +- protocol error codes (§8) +- profile and binding names (§3, §10.1) + +Whoever reads one MUST tolerate a value it does not recognise and MUST NOT act +on one it does not know. §8 says how a caller treats an unrecognised error code. **Store, not platform.** This specification keys on `store`, never on device platform. One device platform can host several stores — an Android build can @@ -5548,8 +5708,9 @@ unlike `price`, it carries no provenance. Entitlement is carried as `subscription.active`, and where a `subscription` member is present that is the field to read — never a re-derivation from -`state`. A store that keeps no canonical subscription record sends no snapshot; -there the `entitlement.*` event type itself carries the decision (§9.5). +`state`. An `entitlement.*` event may omit the snapshot where the store keeps no +canonical subscription record; its event type then carries the decision (§9.5). +No implementation emits that shape yet (§14). **Entitlement is not derivable from `state` alone**, and this is where naive implementations go wrong: @@ -5666,7 +5827,11 @@ a consumer MUST ignore a profile name it does not recognise. A provider implements a profile **completely or not at all**. It MUST declare in its capability descriptor (§10) every profile it serves and MUST NOT declare one it serves partially or does not pass conformance for (§11). -Profiles version independently as MAJOR.MINOR; a caller pins on the major. +A provider whose descriptor lists profiles MUST fail an operation from any +profile it does not list with `UNSUPPORTED_PROFILE`. Authorization comes first +(§5): a credential the provider did not issue for the operation's role still +gets `UNAUTHORIZED` or `FORBIDDEN`. Profiles version independently as +MAJOR.MINOR; a caller pins on the major. --- @@ -5720,10 +5885,12 @@ input to it, select or mutate account state. ### 4.2 subscriptionStatus -A developer backend reads one user's subscription standing: an `active` gate -for the user as a whole, plus the most relevant record — the current -entitling subscription when one exists, otherwise the provider's most recent -record as context, and no record member at all when the provider has none. +A developer backend reads one user's subscription standing. `active` says +whether the user holds a currently entitling subscription. The result also +carries the most relevant record — the current entitling subscription when one +exists, otherwise the provider's most recent record as context, and no record +member at all when the provider has none. Access that does not come from a +subscription appears only in `entitlements` (§4.3). The snapshot is **tokenless by construction**: no purchase token, store transaction identity, signed receipt, or provider-internal record identifier @@ -5732,10 +5899,17 @@ because with it a shipped app could walk arbitrary user identities. ### 4.3 entitlements -The access decision for one user: every product whose gate is open at the -provider's read time, with the entitling records. Unknown, expired, and -ambiguous records contribute nothing. The same tokenless and server-role -rules as §4.2 apply. +The access decision for one user: `productIds` lists every product whose gate +is open at the provider's read time, and `subscriptions` the entitling +subscription records. A product can be granted without a subscription record, +such as a durable purchase, so `productIds` may name products no record +carries; every record's product is in `productIds`. Unknown, expired, and +ambiguous records contribute nothing. The same tokenless and server-role rules +as §4.2 apply. + +A provider that rechecks access with a store and cannot get its answer MUST +fail the read with `VERIFICATION_FAILED` (§8) rather than answer from what it +has. ### 4.4 bindPurchase @@ -5744,12 +5918,13 @@ the identity space of §2.4. Server role only: token possession is deliberately not proof of ownership, so binding is a decision the caller's authenticated backend makes, never a shipped app. -Binding is idempotent and never moves an existing binding. `bound: false` -covers every non-binding outcome — unknown evidence, evidence bound to a -different user, a store the provider cannot bind — without distinguishing -them, so the operation cannot probe whether someone else's purchase exists. -How a provider recovers a purchase bound to the wrong user is management -plane, outside this contract. +Binding is idempotent and never moves an existing binding. For a store the +provider integrates, `bound: false` covers every non-binding outcome — unknown +evidence, evidence bound to a different user — without distinguishing them, so +the operation cannot probe whether someone else's purchase exists. A store the +provider does not integrate is `UNSUPPORTED_STORE`, as in §4.1. How a provider +recovers a purchase bound to the wrong user is management plane, outside this +contract. ### 4.5 eraseUser @@ -5775,8 +5950,8 @@ runner reads it the same way a caller does. ## 5. Authentication and trust The protocol standardizes **roles and rules**, not credential formats. How a -provider issues, names, or rotates credentials is its own business; no -prefix, length, or issuer is part of this contract. +provider issues, names, or rotates credentials is its own business; a +credential's prefix, length, and issuer are outside this contract. | Role | Holder | May call | | ---------------- | ---------------------------------- | ---------------------------------------------------- | @@ -5786,27 +5961,27 @@ prefix, length, or issuer is part of this contract. Both bindings MUST enforce: -- Credentials travel in the `Authorization` header. A provider MUST NOT - accept a secret in a URL path or query string, where proxies and logs - retain it. +- A credential travels as `Authorization: Bearer ` (RFC 6750). + A provider MUST NOT accept a secret in a URL path or query string, where + proxies and logs retain it. - Auth failures fail close: no credential is `UNAUTHORIZED`, a credential of the wrong role is `FORBIDDEN`, and neither response reveals whether the target of the call exists. - For an operation that requires the **server** role, authorization precedes - input validation: a caller without a valid server credential MUST receive - `UNAUTHORIZED` or `FORBIDDEN`, never a verdict about its input — an + input validation. A caller without a valid server credential MUST receive + `UNAUTHORIZED` or `FORBIDDEN`, never a verdict about its input: an input-validation answer would let an unauthenticated caller map the privileged surface (which stores bind, which members exist, which bounds - apply). Transport-shape failures — an unparseable or oversized body, or a - GraphQL document that fails parsing or validation — MAY still precede - authorization: they say nothing operation-specific. Variable coercion - against the operation input IS input validation, not transport shape — a - GraphQL engine coerces variables before any resolver runs, so a provider - that authorizes only inside resolvers violates this rule and MUST - authorize the operation before executing the document. Verification-role - operations are exempt - because their input schema is the published client contract an application - already ships with. + apply). + - Verification-role operations are exempt, because their input schema is + the published client contract an application already ships with. + - Transport-shape failures MAY still precede authorization, because they + say nothing operation-specific: an unparseable or oversized body, or a + GraphQL document that fails parsing or validation. + - Variable coercion against the operation input is input validation, not + transport shape. A GraphQL engine coerces variables before any resolver + runs, so a provider MUST authorize the operation before executing the + document; authorizing only inside resolvers violates this rule. - The verification role and the server role are distinct credentials. A provider MUST NOT let a verification credential reach an account read or mutation, which is what blocks arbitrary-`userId` lookups from shipped @@ -5841,6 +6016,8 @@ offline bundle. `Content-Type: application/json`. - Success is exactly the operation's `successStatus`. Every failure returns the status §8 assigns to its code, with a `ProtocolErrorResponse` body. +- A request whose method and path under `/commerce/v1` match no operation + fails with `NOT_FOUND`. - An unrecognised input member is ignored (§4), and a caller MUST ignore unrecognised result members — the same open-object rule the event envelope follows. @@ -5886,15 +6063,17 @@ provider MAY still gate introspection behind a credential. delivered at `200`. This includes a refusal decided before execution, such as an authorization or rate-limit rejection; a pre-execution refusal omits the `data` member. -- A request-level failure — the document or variables themselves could not - be processed: unparseable document, validation failure, variable coercion - — MAY carry no protocol code or MAY carry the generic `INVALID_REQUEST`, - never a more specific code. The two categories are exclusive per envelope: - one `errors` array is either all coded or all codeless — a codeless entry - riding beside coded ones would be invisible to every code check. It omits the `data` member entirely, and only - the codeless form MAY be delivered as HTTP `400` instead of `200`. A - caller treats either form as `INVALID_REQUEST`; only where the request - died differs. +- A request-level failure is one where the document or variables could not + be processed: an unparseable document, a validation failure, or variable + coercion. + - It MAY carry no protocol code or the generic `INVALID_REQUEST`, never a + more specific code. + - Its response omits the `data` member entirely. + - Only its codeless form MAY be delivered as HTTP `400` instead of `200`. + - A caller treats either form as `INVALID_REQUEST`; only where the request + died differs. +- One `errors` array is either all coded or all codeless. A codeless entry + beside coded ones would be invisible to every code check. - GraphQL cannot express omitted-versus-null on a selected member: a member the provider omitted comes back as `null`. Operation types therefore never make `null` meaningful (the compiler rejects a nullable omittable member), @@ -5922,13 +6101,11 @@ traces, or implementation source paths. | `INVALID_REQUEST` | 400 | The input is malformed or fails the operation schema | | `UNAUTHORIZED` | 401 | No usable credential was presented | | `FORBIDDEN` | 403 | The credential's role may not call this operation | -| `NOT_FOUND` | 404 | The addressed resource does not exist | -| `PURCHASE_NOT_FOUND` | 404 | The evidenced purchase is unknown, where an operation distinguishes that | -| `CONFLICT` | 409 | The request contradicts current state | +| `NOT_FOUND` | 404 | The REST method and path match no operation (§6) | | `UNSUPPORTED_STORE` | 422 | The provider does not integrate the named store | | `RATE_LIMITED` | 429 | Too many requests; retry after the signalled delay | | `INTERNAL_ERROR` | 500 | The provider failed internally | -| `UNSUPPORTED_PROFILE` | 501 | The operation belongs to a profile this provider does not serve | +| `UNSUPPORTED_PROFILE` | 501 | The operation belongs to a profile the provider does not declare (§3) | | `VERIFICATION_FAILED` | 502 | The provider could not obtain a verdict — never the store rejecting evidence | The space is open: a MINOR version can add a code, so a caller MUST treat an @@ -6380,9 +6557,10 @@ The consumer revokes access on `entitlement.revoked`. It could equally act on same meaning for every store — including a store that produces no subscription lifecycle at all (§10). -> On such a store the event arrives with **no `subscription` member**, because -> there is no canonical record to snapshot. `eventType` alone then carries the -> access decision, which is why the reference consumer below handles both. +> For such a store an entitlement event would carry **no `subscription` +> member**, because there is no canonical record to snapshot, and `eventType` +> alone would carry the access decision. No implementation emits that shape yet +> (§14), but the schema allows it, so the reference consumer below handles both. #### What the consumer had to know @@ -6489,8 +6667,9 @@ Each capability carries **two** booleans, deliberately separate: They differ in practice. Amazon publishes Real-Time Notifications that a given backend may not have integrated; that is an implementation gap, not a store -limitation, and collapsing the two into one boolean hides which one it is. A -`notes` string is **required** whenever either is false or the two disagree. +limitation, and collapsing the two into one boolean hides which one it is. +`implementation` MUST NOT be true where `provider` is false. A `notes` string +is **required** whenever either is false. `examples/provider-capabilities.json` is the reference implementation's own descriptor. Read its `implementation` axis as one backend's answer, not as the @@ -6589,17 +6768,37 @@ import { runConformance, } from "@hyodotdev/openiap-commerce-protocol/conformance"; +// Bare credential values; the adapters send them as Bearer tokens (§5). +const credentials = { + verification: process.env.COMMERCE_VERIFICATION_TOKEN, + server: process.env.COMMERCE_SERVER_TOKEN, +}; +const adapters = [ + createRestAdapter({ + baseUrl: process.env.COMMERCE_BASE_URL, + fetch, + credentials, + }), +]; +if (process.env.COMMERCE_GRAPHQL_URL) { + adapters.push( + createGraphqlAdapter({ + url: process.env.COMMERCE_GRAPHQL_URL, + fetch, + credentials, + }), + ); +} + const report = await runConformance({ - adapters: [ - createRestAdapter({ baseUrl, fetch, credentials }), - createGraphqlAdapter({ url: graphqlUrl, fetch, credentials }), - ], + adapters, Ajv, - // The same role-to-credential map the adapters use — required, so the - // runner can reject an error message that echoes a credential. + // Required: the runner rejects an error message that echoes a credential. credentials, - eventsAdapter, // required when the descriptor declares the events profile + // Add your eventsAdapter here if the descriptor declares the events profile. }); +console.log(JSON.stringify(report, null, 2)); +process.exitCode = report.ok ? 0 : 1; ``` It is offline and decentralized by construction: it talks only through the @@ -6617,33 +6816,39 @@ signing-only adapter. ### 11.3 What the vectors prove — and what they cannot The operation vectors (`generated/vectors/operations.json`) exercise auth -negatives, invalid and unknown-member inputs, unsupported stores, mismatched -evidence, idempotent repeats, tokenless responses, error-code and -HTTP-status agreement, capability honesty, and REST/GraphQL parity. Their -purchase evidence is fake but well-formed, so a provider without store -credentials still verifies its transport contract; a verdict for that -evidence is accepted as either a schema-valid result or -`VERIFICATION_FAILED`. +negatives, invalid and unknown-member inputs, unsupported stores and profiles, +mismatched evidence, unknown users, idempotent repeats, tokenless responses, +error-code and HTTP-status agreement, capability honesty, and REST/GraphQL +parity. Their purchase evidence is fake but well-formed, so a provider without +store credentials still verifies its transport contract; a verdict for that +evidence is accepted as either a schema-valid result or `VERIFICATION_FAILED`. They therefore certify the **contract**, not the **stores**: passing says nothing about whether real Apple or Google receipts validate correctly. -Beyond the operation vectors, the runner also checks the capability -descriptor's version agreement against the manifest and — on the GraphQL -binding — probes that the endpoint is a real executor (a malformed document, -an undefined field, and a mistyped variable must each be rejected, without -echoing the submitted value; introspection, where enabled, must agree -STRUCTURALLY with the generated signature — kinds, field and argument types -with their nullability, input members, closed enum value sets, and closed -object member sets. A compatible MINOR may add types, nullable arguments, and -members to open objects; it cannot extend a closed object). Event Delivery conformance is likewise separate — §9's -signature, delivery-envelope, response-semantics, and lifecycle vectors -cover it, driven through the provider's events adapter — and a signing-only -provider does not pass it. The events vectors do not reach everything §9 -requires of a production emitter: the §9.3 event-document schema, §9.4.4 -backoff and dead-lettering, §9.4.5 destination safety, and §9.2 store -mapping are certified by an implementation's own tests, not by this -adapter surface. And a provider can pass while serving fixture data; -conformance is a floor, not an audit. + +Beyond the operation vectors, the runner checks: + +- that the capability descriptor's versions agree with the manifest; +- on the GraphQL binding, that the endpoint is a real executor: a malformed + document, an undefined field, and a mistyped variable must each be rejected + without echoing the submitted value; +- that introspection, where enabled, agrees structurally with the generated + signature: kinds, field and argument types with their nullability, input + members, closed enum value sets, and closed object member sets. A + compatible MINOR may add types, nullable arguments, and members to open + objects; it cannot extend a closed object. + +The adapters reach declared operations only, so §6's `NOT_FOUND` for an +unmatched method and path is certified by an implementation's own tests. + +Event Delivery conformance is separate. §9's signature, delivery-envelope, +response-semantics, and lifecycle vectors cover it, driven through the +provider's events adapter, and a signing-only provider does not pass it. The +events vectors do not reach everything §9 requires of a production emitter: +the §9.3 event-document schema, §9.4.4 backoff and dead-lettering, §9.4.5 +destination safety, and §9.2 store mapping are certified by an +implementation's own tests, not by this adapter surface. And a provider can +pass while serving fixture data; conformance is a floor, not an audit. --- @@ -6659,9 +6864,9 @@ facts table use the same value as `commerceProtocolVersion`. **Consumers pin on A MINOR that leaves the event body untouched does not oblige an emitter to change `eventVersion`: that member names the version the body conforms to, not the newest version published. Until the first stable package release, 1.0 stays -open for additive documents, so a new document does not move the protocol -version at all. The npm package version is separate again, and moves only when -the release workflow publishes. +open for additive changes: a new document, or an existing error code declared on +another operation, does not move the protocol version at all. The npm package +version is separate again, and moves only when the release workflow publishes. While the package major is `0`, that latitude extends to renaming a wire member: the protocol major does not move, because moving it would relocate @@ -6695,7 +6900,7 @@ the retired name for as long as the code lives. | --------------------------------------------------------------------------------------------------------- | ----------------- | | New optional member on an open object | MINOR | | New event type | MINOR | -| New value in an open value space (`store`, `environment`, `cancellationReason`, `eventType`) | MINOR | +| New value in an open value space (§2.1 lists them) | MINOR | | New operation, new profile, or new optional operation input member | MINOR | | New protocol error code, or a new evidence member for a new store | MINOR | | New document: a schema root, its example, and a MUST tying it to an existing document | MINOR once stable | @@ -6821,6 +7026,16 @@ storage or tooling. product; what is absent is the one-time purchase's economic-event taxonomy. - **Refund amounts and partial refunds.** `subscription.refunded` reports that a refund occurred, not how much was returned. +- **Entitlement events without a subscription snapshot.** The event schema + allows an `entitlement.*` event with no `subscription` member, the shape a + store with no canonical subscription record would produce (§2.3, §9.5). No + implementation emits one yet. +- **Purchase-not-found and conflict errors.** No 1.0 operation reports an + unknown purchase or a conflicting state as an error: `bindPurchase` answers + `bound: false` for both (§4.4). A later version that needs them adds codes + (§12). PURCHASE_NOT_FOUND and CONFLICT were removed before 1.0 without a + version move: no operation ever declared them, so pinned callers only + delete dead branches. - **Trial and introductory-offer state.** Offers are catalog metadata here, not a property of a live subscription. - **Storefront and country.** diff --git a/knowledge/external/storekit2-api.md b/knowledge/external/storekit2-api.md index 4555a1dcd..95686688f 100644 --- a/knowledge/external/storekit2-api.md +++ b/knowledge/external/storekit2-api.md @@ -10,8 +10,8 @@ This document provides external API reference for Apple's StoreKit 2 framework. | `Product.SubscriptionInfo.RenewalInfo.eligibleWinBackOfferIDs` | iOS 18.0 | Query win-back offer eligibility before purchase | | Consumable transaction history | iOS 18.0 | Opt-in via `SKIncludeConsumableInAppPurchaseHistory` Info.plist key | | StoreKit `Message.billingIssue` | iOS / Mac Catalyst 16.4, visionOS 1.0 | Listener for subscription billing issues (`Message` is unavailable on macOS, tvOS, and watchOS) | -| UI context for purchases | iOS 18.2 | Required for proper payment sheet display | -| External purchase notice | iOS 17.4 | `ExternalPurchase.presentNoticeSheet()` | +| UI context for purchases | iOS 17.0 | `purchase(confirmIn:)` takes a `UIScene`; iOS 18.2 adds `UIViewController`, macOS 15.2 `NSWindow` | +| External purchase notice token | iOS 17.4 | `ExternalPurchase.canPresent`; `presentNoticeSheet()` returns a token | | `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) | | `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) | | `Transaction.offerPeriod` | iOS 18.4 | Offer period information on Transaction | @@ -360,9 +360,11 @@ if renewalInfo.renewalOfferType == .winBack { } ``` -## UI Context for Purchases (iOS 18.2+) +## UI Context for Purchases -Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets: +`purchase(confirmIn:)` takes a `UIScene` from iOS 17.0; iOS 18.2 adds a +`UIViewController` overload and macOS 15.2 an `NSWindow` one. Apple recommends a UI-context purchase API over +`purchase(options:)` everywhere except watchOS: ```swift // iOS/iPadOS/tvOS/visionOS: UIViewController @@ -541,10 +543,11 @@ By default, `Transaction.all` omits finished consumables. Opt in by adding this With the key set, finished consumable transactions are included in `Transaction.all`, `Transaction.latest(for:)`, and `Product.latestTransaction`. -## External Purchase Support (iOS 17.4+) +## External Purchase Support -`ExternalPurchase.presentNoticeSheet()` / `ExternalPurchaseLink.open(url:)` -ship on iOS 17.4+. The follow-on custom-link APIs +`ExternalPurchase.presentNoticeSheet()` ships on iOS 15.4. `canPresent` and the +sheet's `continuedWithExternalPurchaseToken(token:)` result arrive in iOS 17.4. +`ExternalPurchaseLink.open(url:)` is iOS 17.5+. The custom-link APIs (`ExternalPurchaseCustomLink.isEligible`, `showNotice(type:)`, `token(for:)`) are iOS 18.1+. diff --git a/knowledge/internal/03-coding-style.md b/knowledge/internal/03-coding-style.md index d600fb690..b10629a95 100644 --- a/knowledge/internal/03-coding-style.md +++ b/knowledge/internal/03-coding-style.md @@ -27,6 +27,25 @@ contracts; it requires meeting them with the fewest independent concepts. consolidated. Prefer one understandable path over several defensive fallback paths. +#### Clean Up Once It Works + +A change is not done when it first passes. Reread the diff and the code it +touches for smells, and fix them without being asked. A smell you run into +while working counts too, even in code the change does not touch: + +- duplicated logic, parallel branches that compute one decision, and helpers + copied between files; +- layers, fallbacks, or checks that another part of the system already + guarantees; +- `as any`, which is never allowed. Type the value or narrow it; in tests, use + typed fixtures and `jest.mocked`, and mark input a test passes on purpose to + reach a runtime check with `// @ts-expect-error` and the reason. + `as unknown as T` hides the same problem; +- comments the code now contradicts, comments longer than their point, and + dead or unreachable code (see "Write for a Human Reading It Cold"). + +Rerun the checks afterwards: a cleanup that changes behavior is a bug. + ### 1. Explicit Over Implicit Always be explicit about types and intentions: @@ -302,6 +321,38 @@ isActive = purchaseState == PurchaseState.Purchased Section banners (`// --- Runner ---`) are fine when a file has genuinely distinct parts; do not add them to short files. +### Write for a Human Reading It Cold + +A comment is read by someone who did not write the code and has one question. +Answer it in plain words they can take in at a glance. + +- Lead with the point. Put the constraint or the reason first, not a setup. +- One idea per comment, in short sentences. If it needs "because ... so ... + which means ...", split it or cut it. +- Use ordinary words and name concrete things: the store, the task, the file. + Avoid abstract phrasing such as "a signal that disagrees stops the build" + when "an openiapStore pin and a Horizon task fail the build" says it. +- Say it once. A rule explained in the file header is not re-explained at each + use; the use can say nothing, or point at the header. +- Leave out how the code got here: review rounds, earlier bugs, "once", "twice", + "used to". That history is in git. + +These are the habits that make a comment read like AI prose. Remove them on +sight, in the code you change and in the code you pass through. + +```groovy +// ❌ INCORRECT — an essay: history, hedges, and three ideas in one block +// Gradle task options that take a separate value, derived from `help --task` +// over `tasks --all` under a Gradle 8 and a Gradle 9, because each major has +// tasks the other does not ... Anything unlisted is treated as a flag, so a +// flag never eats the task after it ... The cost runs the other way ... + +// ✅ CORRECT — what the list is, and what happens when it is incomplete +// Task options that take a separate value, from Gradle's `help --task` output. +// An unlisted option's value may be read as a task; the graph check then fails +// unless the value also names a task the build runs. +``` + ### Doc Comments Are Not the Docs Site A public API's doc comment states the **contract**; `packages/docs` states the diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index 5fa6215b2..9506f37b7 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -209,7 +209,7 @@ For every new/changed handler in the generated types, verify **all five** of the | **expo-iap** | `src/types.ts` (generated) | `src/modules/ios.ts` / `android.ts` export, re-exported from `src/index.ts` | `ios/ExpoIapModule.swift` `AsyncFunction`, `android/.../ExpoIapModule.kt` | Not required (flat exports) | `src/modules/__tests__/*.test.ts` | | **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift` and `macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | | **kmp-iap** | `library/src/commonMain/.../openiap/Types.kt` (generated interface) | exposed via `KmpInAppPurchase` / `kmpIapInstance` | `library/src/iosMain/.../InAppPurchaseIOS.kt` — must call `openIapModule.WithCompletion { ... }`, **never** `throw UnsupportedOperationException` | Not required (interface dispatch) | `library/src/commonTest/` if testable cross-platform | -| **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | `make test` covers generated types, API surface, native-extension loading, envelope parsing, and public GDScript behavior; physical devices remain required for store purchases | +| **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | `make test` covers generated types, API surface, native-extension loading, envelope parsing, and public GDScript behavior; physical devices remain required for store purchases | | **maui-iap** | `src/OpenIap.Maui/Types.cs` (generated) | `OpenIap.QueryResolver` / `MutationResolver` interfaces in `Types.cs`; `IOpenIap` adds the native purchase-listener contract; static facade is `OpenIap.Maui.OpenIapClient`; app-facing IAPKit helpers are exposed via `OpenIapClient.KitApi(...)` | Android: `OpenIapMauiModule.kt` in `libraries/maui-iap/android/openiap/` (JSON-shaped Java facade over `packages/google`), bound by `OpenIap.Maui.Bindings.Android.csproj`, consumed by `Platforms/Android/OpenIapAndroid.cs`. Google Billing / Play Services / Gson / AndroidX / Kotlin dependencies must stay NuGet `PackageReference`s, not fat-bundled AARs. iOS / macCatalyst: existing `OpenIapModule+ObjC.swift` bridge in `packages/apple`, bound by hand-written `OpenIap.Maui.Bindings.iOS/ApiDefinition.cs`, consumed by `Platforms/iOS/OpenIapIOS.cs` (+ subclass `OpenIapMacCatalyst`). | Not required (interface dispatch) | OpenIap.Maui 2.x targets supported .NET 10 only. The example app `libraries/maui-iap/example/OpenIap.Maui.Example` builds for net10.0-android / net10.0-ios / net10.0-maccatalyst; package CI builds net10 shared, Android, iOS, and macCatalyst TFMs; xUnit covers generated serialization, error mapping, and the `KitApiClient` HTTP contract (manual device testing remains for purchase flow) | ### Platform suffix rule (who needs what) @@ -299,6 +299,103 @@ The Google package supports **three build flavors**: - `src/horizon/` - Meta Horizon specific implementations - `src/amazon/` - Amazon Appstore specific implementations +### Store Selection + +One rule picks the Android store everywhere, and the developer never edits a +file to switch. Credentials (the Horizon app id, the Amazon +`AppstoreAuthenticationKey.pem`) stay in the project permanently and are inert +on the other stores; they never select anything. + +```text +1. explicit openiapStore= -P / ORG_GRADLE_PROJECT_openiapStore / gradle.properties + (legacy horizonEnabled, fireOsEnabled, openiapPlatform=none: still read, deprecation warning) +2. variant a requested task carries a store flavor: assembleHorizonRelease, installAmazonDebug +3. device debug tasks only: the adb device ANDROID_SERIAL names, or the single + attached one -> Quest = horizon, Fire = amazon +4. play +``` + +A store pin against a different task flavor, two store flavors named by the +requested tasks, and a pin against a legacy flag each fail the build. Opting out +with `openiapStore=none` never conflicts with a task flavor, because it links +nothing; it does still conflict with a legacy flag that names a store. An anchor +task that +builds every flavor — `assemble`, or `assembleDebug` reaching a source-included +openiap-google — is not that case and is allowed. The device is a fallback, not a +competing signal — a pin or a flavor outranks it without complaint. A release +build never consults a device, and several attached devices select nothing +unless `ANDROID_SERIAL` names one. The device step works under the +configuration cache: Gradle re-runs the probe before reusing a cached +configuration, so a different device reconfigures the build. The choice is +logged once: +`openiap: store= (source=explicit|variant|device|default; )`. + +**Vocabulary.** Store ids are `play`, `horizon`, `amazon`, plus `auto` (the +default) and `none` (the Flutter opt-out that links no Android IAP SDK). Aliases +are normalized at the input boundary only: `google`, `gplay`, `googleplay`, +`google-play`, `gms` → `play`; `meta`, `quest` → `horizon`; `fire`, `fireos`, +`fire-os` → `amazon`. `IapStore` in the schema is the _runtime_ store on a +purchase and keeps its own names. + +**SSOT.** `packages/google/gradle/openiap-store.gradle` implements the rule; +edit only that file. A Gradle script cannot ship in the AAR, so +`libraries/react-native-iap/android`, `libraries/expo-iap/android`, and +`libraries/flutter_inapp_purchase/android` symlink it, as the libraries do with +`openiap-versions.json`, and `bun audit:parity` checks the link targets. Each +wrapper publishes it as a real file: the npm release steps copy it over the +link, and `dart pub publish` follows the link. The OpenIAP Gradle plugin +(`packages/google/gradle-plugin`, id `io.github.hyochan.openiap`) packs the same +file into its jar at build time. Every other build system reads the same names: + +| Consumer | Input | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------- | +| react-native-iap, expo-iap, Flutter | wrapper `build.gradle` applies the script; example apps do the same | +| expo-iap config plugin | writes no store; deprecated `modules.horizon` / `modules.amazon.fireOS` still pin, with a warning | +| kmp-iap | library flavors match an app `platform` dimension, or the Gradle plugin picks one | +| OpenIAP Gradle plugin (native, KMP) | applied in settings; selects kmp-iap's store variant and swaps `openiap-google` for the store | +| maui-iap | package targets at app build: `OpenIapStore` (alias `OpenIapAndroidStore`), Debug-build device, play | +| godot-iap | export option `openiap/android_store`; `auto` follows the device on a debug export, else play | +| `openiap doctor` | reads `openiapStore`, `openiapPlatform` and the legacy flags with the same table | + +`bun audit:parity` compares all five alias tables — the resolver, the doctor, +the Godot helper, the runtime facade in `OpenIapStore.kt` and the MAUI package +targets — because a store that resolves differently in two layers of one build +is exactly what this mechanism exists to prevent. + +**Regression suite.** Every rule above is asserted by +`packages/google/scripts/verify-store-resolver.sh`, which CI runs in the Test +Android job. It covers abbreviated task names such as `aHR`, which once let +a Horizon build link the Play SDK and now fail at the task-graph check: + +```bash +cd packages/google && bash scripts/verify-store-resolver.sh +``` + +`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved +store reaches the published `openiap-google` and `kmp-iap` artifacts in an app, +a KMP library module, and a module with its own `platform` flavors (which the +plugin leaves alone). It needs an Android SDK and the network. + +It applies the real resolver to the fixture in +`packages/google/compatibility/store-resolver`, so no Android SDK, device, or +network is needed; `compatibility/store-resolver/fake-adb` stands in for adb and +reports whatever device the case declares. Each case asserts a resolved +`store/source` pair, or that the build fails with a named message. The suite +covers pins and their aliases, the legacy flags and their conflicts, the +`none` opt-out, task flavors, every conflict that must fail, device selection +for Quest, Fire and everything else, `ANDROID_SERIAL`, several attached +devices, release builds, `clean`, and the configuration cache. + +**Add a case whenever the rule changes.** A wrong store is invisible on the +machine that built it — it only appears when the artifact reaches a device that +cannot serve that billing SDK, which is after release. The suite is the only +thing standing between a rule change and that outcome, so a new signal, alias, +or conflict lands with its case in the same commit. + +**iOS.** There is one store axis (App Store vs. an alternative marketplace such +as Onside). Marketplace SDKs are linked at build time by an explicit opt-in and +the runtime routes by install source; nothing is guessed at build time. + ### Critical Rules 1. **DO NOT edit generated files**: `openiap/src/main/java/dev/hyo/openiap/Types.kt` is auto-generated diff --git a/knowledge/internal/05-docs-patterns.md b/knowledge/internal/05-docs-patterns.md index a374aadb2..3d54f606d 100644 --- a/knowledge/internal/05-docs-patterns.md +++ b/knowledge/internal/05-docs-patterns.md @@ -432,5 +432,5 @@ the `next` branch. Preserve the change evidence, then write one concise, package-grouped entry after stable promotion on `main`. Do not use `openiap-versions.json` to derive React Native, Expo, Flutter, -Godot, KMP, or MAUI versions; that manifest tracks only `spec`, `google`, and -`apple`. +Godot, KMP, or MAUI versions; that manifest tracks only `clientProtocol`, +`google`, and `apple`. diff --git a/knowledge/research/bibliography.md b/knowledge/research/bibliography.md index 0f682eaf1..1af544f2d 100644 --- a/knowledge/research/bibliography.md +++ b/knowledge/research/bibliography.md @@ -292,8 +292,8 @@ Empirical Software Engineering, 2022. - Finding: 20.1% of non-major upgrades contain breaking changes; 7.9% of clients are actually impacted. -- OpenIAP relevance: quantifies the risk our floor policy in - `openiap-versions.json` and release-state audits exist to prevent. +- OpenIAP relevance: quantifies the risk the schema semver guard and the + Client Protocol version check exist to prevent. - Applied: `specs/client/scripts/audit-schema-semver.mjs` (backlog R1). ### raemaekers2017semver diff --git a/libraries/expo-iap/android/build.gradle b/libraries/expo-iap/android/build.gradle index 74d919374..8f09be1c2 100644 --- a/libraries/expo-iap/android/build.gradle +++ b/libraries/expo-iap/android/build.gradle @@ -53,12 +53,9 @@ if (!(googleVersion instanceof String) || !googleVersion.trim()) { def googleVersionString = googleVersion.trim() apply from: project.file('openiap-android-sdk.gradle') -def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false -def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false -if (horizonEnabled && fireOsEnabled) { - throw new GradleException("expo-iap: horizonEnabled and fireOsEnabled cannot both be true") -} -def openiapFlavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') +// Store selection: explicit property > task flavor > connected debug device > play. +apply from: project.file('openiap-store.gradle') +def openiapFlavor = openIapResolveStore('expo-iap').store // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. @@ -111,17 +108,15 @@ dependencies { testImplementation "junit:junit:4.13.2" testImplementation "org.json:json:20260719" - // Use OpenIAP Google module only; avoid direct BillingClient dependency + // `api`, so an app's own native code can use dev.hyo.openiap through this module. if (findProject(":openiap-google") != null) { - implementation project(":openiap-google") - } else if (fireOsEnabled) { - // Use openiap-google-amazon for Fire OS when fireOsEnabled is true - implementation "io.github.hyochan.openiap:openiap-google-amazon:${googleVersionString}" - } else if (horizonEnabled) { - // Use openiap-google-horizon for Meta Quest when horizonEnabled is true - implementation "io.github.hyochan.openiap:openiap-google-horizon:${googleVersionString}" + api project(":openiap-google") + } else if (openiapFlavor == 'amazon') { + api "io.github.hyochan.openiap:openiap-google-amazon:${googleVersionString}" + } else if (openiapFlavor == 'horizon') { + api "io.github.hyochan.openiap:openiap-google-horizon:${googleVersionString}" } else { // Fallback to published artifact when local project isn't linked - implementation "io.github.hyochan.openiap:openiap-google:${googleVersionString}" + api "io.github.hyochan.openiap:openiap-google:${googleVersionString}" } } diff --git a/libraries/expo-iap/android/openiap-store.gradle b/libraries/expo-iap/android/openiap-store.gradle new file mode 120000 index 000000000..6ae291887 --- /dev/null +++ b/libraries/expo-iap/android/openiap-store.gradle @@ -0,0 +1 @@ +../../../packages/google/gradle/openiap-store.gradle \ No newline at end of file diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt index e8c98b623..6d19d3de6 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt @@ -272,10 +272,7 @@ object ExpoIapHelper { ) } - /** - * Helper to safely emit an event with error fallback. - * Reduces code duplication across listener handlers. - */ + /** Emits an event, falling back to a purchase error if emitting fails. */ private fun safeEmitEvent( module: Module, scope: CoroutineScope, @@ -362,7 +359,7 @@ object ExpoIapHelper { ) }.onFailure { error -> ExpoIapLog.failure("buffer/send PURCHASE_ERROR", error) - // Critical: if we can't emit the original error, at least try to emit a generic one + // If the original error cannot be emitted, try a generic one. val fallbackPayload = mapOf( "code" to OpenIapError.UnknownError.CODE, diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt index 37cb3782c..bda2e51bf 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt @@ -148,8 +148,8 @@ class ExpoIapModule : Module() { scope.launch { connectionMutex.withLock { try { - // CRITICAL: Set Activity BEFORE calling initConnection - // Horizon SDK needs Activity to initialize OVRPlatform with proper returnComponent + // Set the Activity before initConnection: the Horizon SDK needs it to + // initialize OVRPlatform with the proper returnComponent. // https://github.com/meta-quest/Meta-Spatial-SDK-Samples/issues/82#issuecomment-3452577530 runCatching { currentActivity } .onSuccess { @@ -501,7 +501,6 @@ class ExpoIapModule : Module() { } } - // New name: consumePurchaseAndroid AsyncFunction("consumePurchaseAndroid") { token: String, promise: Promise -> ExpoIapLog.payload("consumePurchaseAndroid", mapOf("token" to token)) scope.launch { @@ -610,9 +609,6 @@ class ExpoIapModule : Module() { scope.launch { try { val openIapProgram = mapBillingProgram(program) - // Note: enableBillingProgram should be called before initConnection - // for proper BillingClient configuration. Here it's called as a fallback - // but may have no effect if BillingClient is already initialized. val result = openIapStore.isBillingProgramAvailable(openIapProgram) val response = mapOf( diff --git a/libraries/expo-iap/example/__tests__/core-functions.test.tsx b/libraries/expo-iap/example/__tests__/core-functions.test.tsx index e02845667..6da5c63d2 100644 --- a/libraries/expo-iap/example/__tests__/core-functions.test.tsx +++ b/libraries/expo-iap/example/__tests__/core-functions.test.tsx @@ -17,8 +17,6 @@ describe('Core Functions Tests', () => { expect(typeof ExpoIap.fetchProducts).toBe('function'); }); - // v3: legacy helpers removed - it('should export requestPurchase function', () => { expect(ExpoIap.requestPurchase).toBeDefined(); expect(typeof ExpoIap.requestPurchase).toBe('function'); diff --git a/libraries/expo-iap/example/__tests__/ios-functions.test.tsx b/libraries/expo-iap/example/__tests__/ios-functions.test.tsx index 05c739b13..57a995b02 100644 --- a/libraries/expo-iap/example/__tests__/ios-functions.test.tsx +++ b/libraries/expo-iap/example/__tests__/ios-functions.test.tsx @@ -108,7 +108,6 @@ describe('iOS Functions Tests', () => { describe('iOS Module Functions', () => { it('should export all iOS-specific functions', () => { - // New IOS suffix functions expect(ExpoIap.syncIOS).toBeDefined(); expect(ExpoIap.isEligibleForIntroOfferIOS).toBeDefined(); expect(ExpoIap.subscriptionStatusIOS).toBeDefined(); diff --git a/libraries/expo-iap/example/app.config.ts b/libraries/expo-iap/example/app.config.ts index d3dfede32..29f8711a7 100644 --- a/libraries/expo-iap/example/app.config.ts +++ b/libraries/expo-iap/example/app.config.ts @@ -25,9 +25,7 @@ const useLocalDev = export default ({config}: ConfigContext): ExpoConfig => { // Check if building for TV (set EXPO_TV=1 before prebuild) const isTV = process.env.EXPO_TV === '1'; - const isFireOsEnabled = process.env.EXPO_IAP_FIREOS === '1'; const isVegaEnabled = process.env.EXPO_IAP_VEGA === '1'; - const isHorizonEnabled = process.env.EXPO_IAP_HORIZON === '1'; const isOnsideEnabled = process.env.EXPO_IAP_ONSIDE === '1'; const iapPluginOptions: ExpoIapPluginOptions = { @@ -42,24 +40,20 @@ export default ({config}: ConfigContext): ExpoConfig => { modules: { // Onside module: iOS only (alternative billing for Korea) onside: isOnsideEnabled, - // Horizon module: Android only (Meta Quest/VR devices) - horizon: isHorizonEnabled, - // Amazon modules: Fire OS Android flavor and Vega OS runtime target + // The Android store follows the connected device; vegaOS generates the Vega target amazon: { - fireOS: isFireOsEnabled, vegaOS: isVegaEnabled, }, }, android: { - // Horizon App ID for Meta Quest/VR devices (required when modules.horizon is true) + // Horizon App ID, written on every prebuild and inert outside Quest horizon: { appId: '31705015229097839', }, }, ios: { - // iOS Alternative Billing configuration (optional) - // Uncomment and configure for external purchase support - // NOTE: Requires Apple approval and proper provisioning profile + // Optional: uncomment for external purchase support. + // Requires Apple approval and a matching provisioning profile. // alternativeBilling: { // // Required: Countries where external purchases are supported (ISO 3166-1 alpha-2) // countries: ['kr', 'nl'], diff --git a/libraries/expo-iap/example/app/all-products.tsx b/libraries/expo-iap/example/app/all-products.tsx index 7b66f9b76..862a5523d 100644 --- a/libraries/expo-iap/example/app/all-products.tsx +++ b/libraries/expo-iap/example/app/all-products.tsx @@ -19,43 +19,14 @@ import {extractErrorMessage} from '../src/utils/errorUtils'; import type {Product, ProductSubscription} from '../../src/types'; /** - * All Products Example - Show All Products and Subscriptions + * All Products example: fetches in-app products and subscriptions separately + * and lists them in one view. * - * Demonstrates fetching all products (both in-app and subscriptions): - * - Fetches in-app products and subscriptions separately - * - Displays products and subscriptions as they come from the API - * - Single view for all product types - * - * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - * 🎯 TypeScript Discriminated Union Type Narrowing Examples - * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - * - * This file demonstrates real-world usage of discriminated union type narrowing - * with OpenIAP gql 1.2.4+ types. See the following functions for examples: - * - * Example 1 (Line ~90): handleShowDetails() - * - Demonstrates combining 'platform' and 'type' discriminators - * - Shows how to narrow to specific types like ProductSubscriptionIOS - * - Includes console.log examples showing type-safe field access - * - * Example 2 (Line ~125): getProductTypeLabel() - * - Shows basic type narrowing using the 'type' discriminator - * - Narrows Product | ProductSubscription -> ProductSubscription - * - * Key discriminator fields: - * - `type`: 'in-app' | 'subs' - Distinguishes products from subscriptions - * - `platform`: 'ios' | 'android' - Distinguishes platform-specific types - * - * Type hierarchy: - * - Product = ProductIOS | ProductAndroid (type: 'in-app') - * - ProductSubscription = ProductSubscriptionIOS | ProductSubscriptionAndroid (type: 'subs') - * - * Benefits: - * ✅ Type-safe access to platform-specific fields and subscriptionOffers - * ✅ Compile-time errors prevent accessing non-existent fields - * ✅ Better IDE autocomplete and IntelliSense - * ✅ Runtime safety - no accessing undefined fields - * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + * handleShowDetails and getProductTypeLabel show how to narrow the product + * union by its discriminators: + * - `type`: 'in-app' (Product = ProductIOS | ProductAndroid) or + * 'subs' (ProductSubscription = ProductSubscriptionIOS | ProductSubscriptionAndroid) + * - `platform`: 'ios' | 'android' */ function AllProducts() { @@ -104,14 +75,8 @@ function AllProducts() { } }, [connected, fetchProducts]); - /** - * 🎯 Type Narrowing Example 1: Platform + Type narrowing - * - * This demonstrates combining both 'platform' and 'type' discriminators - * to narrow down to a specific type (e.g., ProductSubscriptionIOS). - */ + // Narrows by `type`, then by `platform`, down to e.g. ProductSubscriptionIOS. const handleShowDetails = (product: Product | ProductSubscription) => { - // Log type narrowing examples console.log('\n🎯 Type Narrowing Examples for:', product.id); // Example 1: Narrow by type @@ -158,14 +123,8 @@ function AllProducts() { setModalVisible(true); }; - /** - * 🎯 Type Narrowing Example 2: Using 'type' discriminator - * - * This demonstrates how TypeScript narrows the union type - * Product | ProductSubscription using the 'type' field. - */ + // Narrows Product | ProductSubscription by the `type` field. const getProductTypeLabel = (product: Product | ProductSubscription) => { - // Type narrowing using 'type' discriminator if (product.type === 'subs') { // ✅ TypeScript narrows to: ProductSubscription return 'SUBSCRIPTION'; diff --git a/libraries/expo-iap/example/app/alternative-billing.tsx b/libraries/expo-iap/example/app/alternative-billing.tsx index 12c09db68..12754f136 100644 --- a/libraries/expo-iap/example/app/alternative-billing.tsx +++ b/libraries/expo-iap/example/app/alternative-billing.tsx @@ -78,7 +78,7 @@ function AlternativeBillingScreen() { const [isReconnecting, setIsReconnecting] = useState(false); const isVega = isVegaOS(); - // Initialize with billing program config (new API) + // Initialize with billing program config const {connected, products, fetchProducts, finishTransaction} = useIAP({ enableBillingProgramAndroid: Platform.OS === 'android' ? billingProgram : undefined, @@ -148,7 +148,7 @@ function AlternativeBillingScreen() { // Wait a bit for cleanup await new Promise((resolve) => setTimeout(resolve, 500)); - // Reinitialize with new program (new API) + // Reinitialize with new program const config = Platform.OS === 'android' ? {enableBillingProgramAndroid: newProgram} @@ -293,7 +293,7 @@ function AlternativeBillingScreen() { [billingProgram], ); - // Handle Android User Choice Billing (new enableBillingProgramAndroid: 'user-choice-billing') + // Handle Android User Choice Billing (enableBillingProgramAndroid: 'user-choice-billing') const handleAndroidUserChoiceBilling = useCallback((product: Product) => { console.log('[Android] Starting user choice billing:', product.id); @@ -311,9 +311,6 @@ function AlternativeBillingScreen() { // developerBillingOption can be set to specify developer billing behavior }) .then(() => { - // Google will show selection dialog - // If user selects Google Play: onPurchaseUpdated callback - // If user selects alternative: No callback (manual flow required) setPurchaseResult( `🔄 User choice dialog shown\n\nProduct: ${product.id}\n\nIf user selects:\n- Google Play: onPurchaseUpdated callback\n- Alternative: Manual flow required`, ); diff --git a/libraries/expo-iap/example/app/index.tsx b/libraries/expo-iap/example/app/index.tsx index 16d59aca4..cce5a9415 100644 --- a/libraries/expo-iap/example/app/index.tsx +++ b/libraries/expo-iap/example/app/index.tsx @@ -7,12 +7,12 @@ import { View, Platform, } from 'react-native'; -import {useRouter} from 'expo-router'; +import {useRouter, type Href} from 'expo-router'; import {getStorefront} from 'expo-iap'; type MenuItem = { id: string; - href: string; + href: Href; icon: string; title: string; subtitle: string; @@ -70,12 +70,7 @@ const MENU_ITEMS: MenuItem[] = [ }, ]; -/** - * Example App Landing Page - * - * Navigation to focused purchase flow implementations. - * This demonstrates TypeScript-first, platform-agnostic approaches to in-app purchases. - */ +/** Example app landing page: navigation to each purchase flow example. */ export default function Home() { const router = useRouter(); const [storefront, setStorefront] = useState(null); @@ -113,7 +108,7 @@ export default function Home() { focusable hasTVPreferredFocus={focusedIndex === index} onFocus={() => setFocusedIndex(index)} - onPress={() => router.push(item.href as any)} + onPress={() => router.push(item.href)} style={[ styles.menuItem, focusedIndex === index && styles.menuItemFocused, diff --git a/libraries/expo-iap/example/app/offer-code.tsx b/libraries/expo-iap/example/app/offer-code.tsx index a6ab1201a..f662a985d 100644 --- a/libraries/expo-iap/example/app/offer-code.tsx +++ b/libraries/expo-iap/example/app/offer-code.tsx @@ -11,12 +11,7 @@ import { } from 'react-native'; import {openRedeemOfferCode, useIAP} from 'expo-iap'; -/** - * Offer Code Redemption Example - * - * This example demonstrates how to implement offer code redemption - * functionality for both iOS and Android platforms. - */ +/** Offer code redemption example for iOS and Android. */ const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; diff --git a/libraries/expo-iap/example/app/purchase-flow.tsx b/libraries/expo-iap/example/app/purchase-flow.tsx index 5e9dcb487..d10bffabb 100644 --- a/libraries/expo-iap/example/app/purchase-flow.tsx +++ b/libraries/expo-iap/example/app/purchase-flow.tsx @@ -116,14 +116,8 @@ type PurchaseFlowProps = { }; /** - * Purchase Flow Example - In-App Products - * - * Demonstrates useIAP hook approach for in-app products: - * - Uses useIAP hook for purchase management - * - Handles purchase callbacks with proper types - * - No manual promise handling required - * - Clean success/error pattern through hooks - * - Focused on one-time purchases (products) + * Purchase Flow example: one-time in-app products through the useIAP hook, + * with results delivered to its success and error callbacks. */ function PurchaseFlow({ @@ -729,17 +723,14 @@ function PurchaseFlow({ } /** - * PurchaseFlowContainer - Main IAP Flow Controller - * - * IAP Flow Steps: - * ============================================================ + * PurchaseFlowContainer: the in-app purchase flow. Steps, marked in the code + * below: * 1. initConnection - Store connection (handled by useIAP) * 2. subscribeEvent - Event subscription (onPurchaseSuccess/onPurchaseError) * 3. requestPurchase - 3 options: Apple, Google, Google with offers - * 4. verify purchase - local device | local IAPKit | hosted IAPKit | skip + * 4. verify purchase - local device | local IAPKit | hosted IAPKit | skip * 5. grant entitlement - Update availablePurchases state * 6. finish transaction - Call finishTransaction to complete - * ============================================================ */ function PurchaseFlowContainer() { // ============================================================ diff --git a/libraries/expo-iap/example/app/subscription-flow.tsx b/libraries/expo-iap/example/app/subscription-flow.tsx index d8b9d2e92..fff05a1a1 100644 --- a/libraries/expo-iap/example/app/subscription-flow.tsx +++ b/libraries/expo-iap/example/app/subscription-flow.tsx @@ -102,19 +102,13 @@ function isExpiringSoon(subscription: ActiveSubscription): boolean { } /** - * Subscription Flow Example - Subscription Products + * Subscription Flow example: recurring subscriptions through the useIAP hook, + * with results delivered to its success and error callbacks. * - * Demonstrates useIAP hook approach for subscriptions: - * - Uses useIAP hook for subscription management - * - Handles subscription callbacks with proper types - * - No manual promise handling required - * - Clean success/error pattern through hooks - * - Focused on recurring subscriptions - * - * New subscription status checking API: - * - getActiveSubscriptions() - gets all active subscriptions automatically - * - getActiveSubscriptions(['id1', 'id2']) - gets specific subscriptions - * - activeSubscriptions state - automatically updated subscription list + * Subscription status: + * - getActiveSubscriptions() - all active subscriptions + * - getActiveSubscriptions(['id1', 'id2']) - specific subscriptions + * - activeSubscriptions state - updated automatically */ type SubscriptionFlowProps = { @@ -156,7 +150,6 @@ function SubscriptionFlow({ ); const [purchaseDetailsVisible, setPurchaseDetailsVisible] = useState(false); - // Helper to get subscription title by product ID const getSubscriptionTitle = useCallback( (productId: string | null | undefined): string => { if (!productId) return 'Unknown'; @@ -165,9 +158,6 @@ function SubscriptionFlow({ [subscriptions], ); - // Note: getSubscriptionTier is now defined outside the component for better performance - - // Get current active subscription const getCurrentSubscription = useCallback((): ActiveSubscription | null => { const activeSubs = activeSubscriptions.filter((sub) => sub.isActive); if (activeSubs.length === 0) return null; @@ -914,9 +904,8 @@ function SubscriptionFlow({ const pendingProductId = sub.renewalInfoIOS?.pendingUpgradeProductId; - // Show upgrade card if there's a pending upgrade product that's different - // from the current product. In production, you might want to also check - // willAutoRenew, but Apple Sandbox behavior can be inconsistent. + // Show the card when a different product is pending. Production code may + // also check willAutoRenew, but Apple Sandbox reports it inconsistently. return pendingProductId && pendingProductId !== sub.productId; }, ); @@ -1361,39 +1350,27 @@ function SubscriptionFlow({ } /** - * SubscriptionFlowContainer - Main Subscription IAP Flow Controller - * - * ============================================================ - * Subscription Flow Steps: - * ============================================================ + * SubscriptionFlowContainer: the subscription purchase flow. Steps, marked in + * the code below: * 1. initConnection - Store connection (useIAP handles automatically) * 2. subscribeEvent - Listen for purchase events (onPurchaseSuccess/Error) * 3. requestPurchase - Apple: {sku}, Google: {skus, subscriptionOffers} - * 4. verify purchase - local device | local IAPKit | hosted IAPKit | skip + * 4. verify purchase - local device | local IAPKit | hosted IAPKit | skip * 5. grant entitlement - Update activeSubscriptions state * 6. finish transaction - finishTransaction({purchase, isConsumable: false}) * - * ============================================================ - * Platform Comparison (Subscription Info Availability): - * ============================================================ - * | Information | iOS Client | Android Client | Server | - * |--------------------------|------------|----------------|--------| - * | Auto-renew status | willAutoRenew | isAutoRenewing | Yes | - * | Next renewal product | autoRenewPreference | No | Yes | - * | Pending upgrade/downgrade| pendingUpgradeProductId | No | Yes | - * | Expiration reason | expirationReason | No | Yes | - * | Grace period status | gracePeriodExpirationDate | No| Yes | - * | Billing retry status | isInBillingRetry | No | Yes | - * - * Key: iOS provides rich client-side data, Android needs server calls + * Subscription info on the client (a server can read all of it): + * | Information | iOS | Android | + * |---------------------------|---------------------------|----------------| + * | Auto-renew status | willAutoRenew | isAutoRenewing | + * | Next renewal product | autoRenewPreference | No | + * | Pending upgrade/downgrade | pendingUpgradeProductId | No | + * | Expiration reason | expirationReason | No | + * | Grace period status | gracePeriodExpirationDate | No | + * | Billing retry status | isInBillingRetry | No | * - * ============================================================ - * When to Validate (Server-side recommended): - * ============================================================ - * - After purchase: Verify the purchase is legitimate - * - On restore: Check current status (active/cancelled/refunded/expired) - * - Periodically: Detect refunds and cancellations - * - On app launch: Sync subscription state with server + * Validate on the server after purchase, on restore (current status), + * periodically (refunds and cancellations), and on app launch (state sync). */ function SubscriptionFlowContainer() { // ============================================================ @@ -1983,9 +1960,8 @@ function SubscriptionFlowContainer() { // ============================================================ // On App Launch - Check Existing Subscriptions // ============================================================ - // Check for existing subscriptions when the app starts. - // This handles purchases made while the app was closed. - // iOS: Transaction queue persists unfinished transactions + // Catches purchases made while the app was closed; iOS keeps unfinished + // transactions in its queue. // ============================================================ useEffect(() => { if (connected && subscriptions.length > 0) { diff --git a/libraries/expo-iap/example/jest.config.js b/libraries/expo-iap/example/jest.config.js index 948542583..ccf110680 100644 --- a/libraries/expo-iap/example/jest.config.js +++ b/libraries/expo-iap/example/jest.config.js @@ -5,8 +5,7 @@ process.env.EXPO_PUBLIC_USE_RN_FETCH ??= '1'; module.exports = { preset: 'jest-expo', - // Remove testEnvironment override to let jest-expo handle it - // testEnvironment: 'node', + // No testEnvironment override: jest-expo handles it. // Disable watchman to avoid sandbox/permission issues in CI and sandboxes watchman: false, testMatch: ['**/__tests__/**/*.test.{ts,tsx,js,jsx}'], diff --git a/libraries/expo-iap/example/jest.setup.js b/libraries/expo-iap/example/jest.setup.js index 1734dde1a..d9c6bf919 100644 --- a/libraries/expo-iap/example/jest.setup.js +++ b/libraries/expo-iap/example/jest.setup.js @@ -13,12 +13,10 @@ jest.mock('expo-splash-screen', () => ({ hideAsync: jest.fn(), })); -// Mock react-native Animated API to avoid TouchableOpacity animation issues -// Create a manual mock for Animated to prevent TouchableOpacity errors +// Stub Animated.timing to avoid TouchableOpacity animation errors. jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); - // Override Animated.timing to return a simple mock RN.Animated.timing = () => ({ start: (callback) => callback && callback({finished: true}), stop: jest.fn(), diff --git a/libraries/expo-iap/example/scripts/build-vega-example.mjs b/libraries/expo-iap/example/scripts/build-vega-example.mjs index f15230b4a..50890de27 100644 --- a/libraries/expo-iap/example/scripts/build-vega-example.mjs +++ b/libraries/expo-iap/example/scripts/build-vega-example.mjs @@ -120,10 +120,9 @@ const writeLocalJavaScriptModule = (packageName, source, main = 'index.js') => { fs.writeFileSync(path.join(moduleRoot, main), source, 'utf8'); }; -// Example sources import the library by relative path from any nesting depth -// (`../../src/...` directly under example/src, `../../../src/...` one level -// deeper). The Vega build copies the example without the library checkout, so -// every depth must resolve to the published module instead. +// The Vega build copies the example without the library checkout, so relative +// library imports at every depth (`../../src/...`, `../../../src/...`) must +// resolve to the published module. const expoSourceImportPattern = /from (['"])(?:\.\.\/)+src(\/types|\/utils\/errorMapping)?\1/gu; diff --git a/libraries/expo-iap/example/src/utils/constants.ts b/libraries/expo-iap/example/src/utils/constants.ts index 89a919936..fe23453e3 100644 --- a/libraries/expo-iap/example/src/utils/constants.ts +++ b/libraries/expo-iap/example/src/utils/constants.ts @@ -1,5 +1,4 @@ // Centralized product ID constants for the example app and related tests -// Rename guide: subscriptionIds -> SUBSCRIPTION_PRODUCT_IDS, PRODUCT_IDS remains the same name // One-time purchase product IDs split by consumption behavior export const CONSUMABLE_PRODUCT_IDS: string[] = [ diff --git a/libraries/expo-iap/ios/ExpoIap.podspec b/libraries/expo-iap/ios/ExpoIap.podspec index 86debc739..1b64f7226 100644 --- a/libraries/expo-iap/ios/ExpoIap.podspec +++ b/libraries/expo-iap/ios/ExpoIap.podspec @@ -11,13 +11,10 @@ Pod::Spec.new do |s| s.license = package['license'] s.author = package['author'] s.homepage = package['homepage'] - # WARNING: DO NOT MODIFY iOS platform version from 13.4 - # Changing iOS to 15.0 can cause expo prebuild to exclude the module in certain Expo SDKs (known bug) - # See: https://github.com/hyochan/expo-iap/issues/168 - # Even though StoreKit 2 requires iOS 15.0+, keep iOS at 13.4 for compatibility with affected Expo SDKs - # The iOS 15.0+ requirement is enforced at build time in source code via @available annotations - # - # NOTE: tvOS requires 16.0 because openiap dependency has minimum tvOS deployment target of 16.0 + # Do not change iOS from 13.4: 15.0 can make expo prebuild exclude the module in + # some Expo SDKs (https://github.com/hyochan/expo-iap/issues/168). The source + # enforces StoreKit 2's iOS 15.0+ requirement with @available annotations. + # tvOS is 16.0, the openiap dependency's minimum tvOS deployment target. s.platforms = { :ios => '13.4', :tvos => '16.0' } s.swift_version = '5.9' s.source = { :path => '.' } diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index ee02fc24b..d582bc6bb 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -70,9 +70,8 @@ enum ExpoIapHelper { array } - // Keep Expo IAP compatible with the currently published OpenIAP native - // package while treating authoritative query serialization atomically. - // Its non-throwing helpers use an empty dictionary as the failure sentinel. + // The published OpenIAP package's serializers return an empty dictionary on + // failure instead of throwing; these throw, so a query fails as a whole. static func encodeRequired(_ value: T) throws -> [String: Any] { let encoded = OpenIapSerialization.encode(value) guard !encoded.isEmpty else { diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index a8657ea44..2efcbeaac 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -43,8 +43,7 @@ public final class ExpoIapModule: Module { } AsyncFunction("initConnection") { (config: [String: Any]?) async throws -> Bool in - // Note: iOS doesn't support alternative billing config parameter - // Config is ignored on iOS platform + // iOS ignores the config; it has no alternative billing parameter. await ExpoIapHelper.waitForStoreCleanup() let isConnected = try await OpenIapModule.shared.initConnection() await MainActor.run { self.isInitialized = isConnected } @@ -417,7 +416,7 @@ public final class ExpoIapModule: Module { return hasActive } - // MARK: - External Purchase (iOS 16.0+) + // MARK: - External Purchase AsyncFunction("canPresentExternalPurchaseNoticeIOS") { () async throws -> Bool in ExpoIapLog.payload("canPresentExternalPurchaseNoticeIOS", payload: nil) diff --git a/libraries/expo-iap/package.json b/libraries/expo-iap/package.json index c01dde34a..525261c40 100644 --- a/libraries/expo-iap/package.json +++ b/libraries/expo-iap/package.json @@ -16,7 +16,7 @@ "test": "jest && bun run test:plugin", "test:plugin": "cd plugin && bunx jest", "test:coverage": "jest --coverage", - "verify:consumer-install": "bun run prepublishOnly && node ../../scripts/verify-npm-consumer-install.mjs --package . --package-name expo-iap --required openiap-versions.json --required build/index.js --required build/index.d.ts --required android/build.gradle --required ios/ExpoIap.podspec --required app.plugin.js --required plugin/build/withIAP.js", + "verify:consumer-install": "bun run prepublishOnly && node ../../scripts/verify-npm-consumer-install.mjs --package . --package-name expo-iap --required openiap-versions.json --required build/index.js --required build/index.d.ts --required android/build.gradle --required android/openiap-store.gradle --required ios/ExpoIap.podspec --required app.plugin.js --required plugin/build/withIAP.js", "prepare": "bun run build:plugin && sh -c 'command -v husky >/dev/null 2>&1 && husky || { echo \"husky setup unavailable; skipping\"; exit 0; }'", "prepublishOnly": "bun run clean:plugin && expo-module prepublishOnly && bun run build:plugin", "expo-module": "expo-module", diff --git a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts index 7c502ac65..1a5894c46 100644 --- a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts +++ b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts @@ -1,5 +1,5 @@ import type {ExpoConfig} from '@expo/config-types'; -import {WarningAggregator} from 'expo/config-plugins'; +import {compileModsAsync, WarningAggregator} from 'expo/config-plugins'; import plugin, { applyOnsideInfoPlist, computeAutolinkModules, @@ -8,9 +8,13 @@ import plugin, { normalizeGeneratedGroovyAppBuildGradle, normalizeGeneratedGroovyProjectBuildGradle, resolveAlternativeBillingIOS, + resolveAmazonAppstoreKey, resolveAmazonPlatformFlags, resolveHorizonAppId, resolveModuleSelection, + resolvePinnedAndroidStore, + storeGradleProperties, + syncAmazonAppstoreKey, resolveVegaProjectOptions, syncHorizonAppIdMetaData, } from '../src/withIAP'; @@ -84,91 +88,308 @@ jest.mock('expo/config-plugins', () => { }); describe('android configuration', () => { - const dependencyVersion = require('../../openiap-versions.json').google; - const dependencyRegex = new RegExp( - `io\\.github\\.hyochan\\.openiap:openiap-google:${dependencyVersion}`, - 'g', - ); - - it('adds OpenIAP dependency when missing', () => { - const baseGradle = 'dependencies {\n}\n'; - const result = modifyAppBuildGradle(baseGradle, 'groovy'); - expect(result).toContain( - ` implementation "io.github.hyochan.openiap:openiap-google:${dependencyVersion}"`, - ); - const matches = result.match(dependencyRegex) ?? []; - expect(matches).toHaveLength(1); + it('leaves an app build file without OpenIAP lines untouched', () => { + const baseGradle = + 'android {\n defaultConfig {\n }\n}\ndependencies {\n}\n'; + expect(modifyAppBuildGradle(baseGradle, 'groovy')).toBe(baseGradle); }); - it('keeps existing dependency untouched', () => { - const baseGradle = `dependencies {\n implementation "io.github.hyochan.openiap:openiap-google:0.0.1"\n}\n`; - const result = modifyAppBuildGradle(baseGradle, 'groovy'); - const matches = result.match(dependencyRegex) ?? []; - expect(matches).toHaveLength(1); - expect(result).not.toContain('openiap-google:0.0.1'); - }); - - it('uses Fire OS artifact and flavor when Fire OS is enabled', () => { + it('strips the dependency and fixed strategy that older plugin versions wrote', () => { const baseGradle = [ 'android {', ' defaultConfig {', + ' missingDimensionStrategy "platform", "amazon"', ' }', '}', 'dependencies {', - ' implementation "io.github.hyochan.openiap:openiap-google-horizon:0.0.1"', + ' implementation "io.github.hyochan.openiap:openiap-google-amazon:0.0.1"', + ' implementation "io.github.hyochan.openiap:openiap-google:0.0.1"', '}', '', ].join('\n'); - const result = modifyAppBuildGradle(baseGradle, 'groovy', false, true); + const result = modifyAppBuildGradle(baseGradle, 'groovy'); - expect(result).toContain( - ` implementation "io.github.hyochan.openiap:openiap-google-amazon:${dependencyVersion}"`, - ); - expect(result).toContain( - ' missingDimensionStrategy "platform", "amazon"', - ); - expect(result).not.toContain('openiap-google-horizon:0.0.1'); + expect(result).not.toContain('openiap-google'); + expect(result).not.toContain('missingDimensionStrategy'); + expect(result).toContain('dependencies {'); }); - it('prefers Fire OS over Horizon when both store flags are enabled', () => { + it('strips Kotlin DSL dependency and strategy lines too', () => { const baseGradle = [ 'android {', ' defaultConfig {', + ' missingDimensionStrategy("platform", "horizon")', ' }', '}', 'dependencies {', + ' implementation("io.github.hyochan.openiap:openiap-google-horizon:0.0.1")', '}', '', ].join('\n'); - const result = modifyAppBuildGradle(baseGradle, 'kotlin', true, true); + const result = modifyAppBuildGradle(baseGradle, 'kt'); - expect(result).toContain( - ` implementation("io.github.hyochan.openiap:openiap-google-amazon:${dependencyVersion}")`, - ); - expect(result).toContain( - ' missingDimensionStrategy("platform", "amazon")', + expect(result).not.toContain('openiap-google'); + expect(result).not.toContain('missingDimensionStrategy'); + }); + + it('pins the store only when a module flag asks for it', () => { + expect( + resolvePinnedAndroidStore({ + isFireOsEnabled: false, + isHorizonEnabled: false, + }), + ).toBeNull(); + expect( + resolvePinnedAndroidStore({ + isFireOsEnabled: false, + isHorizonEnabled: true, + }), + ).toBe('horizon'); + }); + + it('warns that a module pin is deprecated but still applies it', () => { + // Dropping the pin silently would move an existing Quest release to Play. + const warn = WarningAggregator.addWarningAndroid as jest.Mock; + warn.mockClear(); + plugin({name: 'app', slug: 'app'} as ExpoConfig, { + modules: {horizon: true}, + }); + + expect(warn).toHaveBeenCalledWith( + 'expo-iap', + expect.stringMatching( + /modules\.horizon \(or EXPO_IAP_HORIZON\) is deprecated.*ORG_GRADLE_PROJECT_openiapStore=horizon/u, + ), ); }); - it('replaces stale platform strategy when returning to Play', () => { - const baseGradle = [ - 'android {', - ' defaultConfig {', - ' missingDimensionStrategy "platform", "amazon"', - ' }', - '}', - 'dependencies {', - ' implementation "io.github.hyochan.openiap:openiap-google-amazon:0.0.1"', - '}', - '', - ].join('\n'); - const result = modifyAppBuildGradle(baseGradle, 'groovy'); + it('does not warn when nothing pins the store', () => { + const warn = WarningAggregator.addWarningAndroid as jest.Mock; + warn.mockClear(); + plugin({name: 'app', slug: 'app'} as ExpoConfig, {}); - expect(result).toContain( - ` implementation "io.github.hyochan.openiap:openiap-google:${dependencyVersion}"`, + expect( + warn.mock.calls.some(([, message]) => + /deprecated/u.test(String(message)), + ), + ).toBe(false); + }); + + it('refuses two modules naming different stores', () => { + // An APK links one billing SDK, so picking one silently would ship the + // other store's users a build that cannot talk to their store. + expect(() => + resolvePinnedAndroidStore({ + isFireOsEnabled: true, + isHorizonEnabled: true, + }), + ).toThrow(/both enabled/u); + }); + + it('fails the prebuild on two store modules instead of skipping every mod', () => { + // The plugin's catch-all once turned this into a warning and returned the + // config with no expo-iap changes. + expect(() => + plugin({name: 'app', slug: 'app'} as ExpoConfig, { + modules: {horizon: true, amazon: {fireOS: true}}, + }), + ).toThrow(/both enabled/u); + }); + + it('keeps the published iOS setup when enableLocalDev has no localPath', () => { + const result = plugin({name: 'app', slug: 'app'} as ExpoConfig, { + enableLocalDev: true, + }) as ExpoConfig & {mods?: {ios?: Record}}; + expect(result.mods?.ios?.podfile).toBeDefined(); + }); + + it('moves the local pod when localPath moves', async () => { + const fs = jest.requireActual('fs') as typeof import('fs'); + const os = jest.requireActual('os') as typeof import('os'); + const path = jest.requireActual('path') as typeof import('path'); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-iap-pod-')); + const podfile = path.join(projectRoot, 'ios', 'Podfile'); + try { + fs.mkdirSync(path.dirname(podfile), {recursive: true}); + fs.writeFileSync( + podfile, + "target 'app' do\n use_expo_modules!\n\n pod 'openiap', :path => '../gone/apple'\nend\n", + ); + const apple = path.resolve(__dirname, '../../../../packages/apple'); + await compileModsAsync( + plugin({name: 'app', slug: 'app'} as ExpoConfig, { + enableLocalDev: true, + localPath: {ios: apple}, + }) as ExpoConfig, + {projectRoot, platforms: ['ios']}, + ); + expect(fs.readFileSync(podfile, 'utf8')).toContain( + `pod 'openiap', :path => '${path.relative( + path.dirname(podfile), + apple, + )}'`, + ); + } finally { + fs.rmSync(projectRoot, {recursive: true, force: true}); + } + }); + + const rootBuild = 'buildscript {\n repositories {\n google()\n }\n}\n'; + const appBuild = + 'android {\n defaultConfig {\n }\n}\n\ndependencies {\n // React Native sets this version\n implementation("com.facebook.react:react-android")\n}\n'; + it.each([ + { + dsl: 'Groovy', + ext: '', + settings: "rootProject.name = 'app'\ninclude ':app'\n", + include: "include ':openiap-google'", + dependency: "implementation project(':openiap-google')", + }, + { + dsl: 'Kotlin', + ext: '.kts', + settings: 'rootProject.name = "app"\ninclude(":app")\n', + include: 'include(":openiap-google")', + dependency: 'implementation(project(":openiap-google"))', + }, + ])( + "drops an earlier local build's $dsl DSL wiring on a published prebuild", + async ({ext, settings, include, dependency}) => { + const fs = jest.requireActual('fs') as typeof import('fs'); + const os = jest.requireActual('os') as typeof import('os'); + const path = jest.requireActual('path') as typeof import('path'); + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'expo-iap-local-'), + ); + const android = path.join(projectRoot, 'android'); + const settingsFile = `settings.gradle${ext}`; + const rootFile = `build.gradle${ext}`; + const appFile = `app/build.gradle${ext}`; + const files: Record = { + [settingsFile]: settings, + [rootFile]: rootBuild, + [appFile]: appBuild, + 'gradle.properties': 'org.gradle.jvmargs=-Xmx2g\n', + 'app/src/main/AndroidManifest.xml': + '\n \n\n', + }; + const read = () => + Object.fromEntries( + [settingsFile, rootFile, appFile].map((file) => [ + file, + fs.readFileSync(path.join(android, file), 'utf8'), + ]), + ); + const prebuild = (options: ExpoIapPluginOptions) => + compileModsAsync( + plugin( + {name: 'app', slug: 'app'} as ExpoConfig, + options, + ) as ExpoConfig, + {projectRoot, platforms: ['android']}, + ); + const local: ExpoIapPluginOptions = { + enableLocalDev: true, + localPath: { + android: path.resolve(__dirname, '../../../../packages/google'), + }, + }; + try { + for (const [file, contents] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(android, file)), { + recursive: true, + }); + fs.writeFileSync(path.join(android, file), contents); + } + + await prebuild(local); + const linked = read(); + expect(linked[settingsFile]).toContain(include); + expect(linked[appFile]).toContain(dependency); + expect(linked[rootFile]).toContain('openIapResolveStore'); + + // expo-iap links an included :openiap-google in place of Maven. + await prebuild({}); + const published = read(); + expect(published[settingsFile]).not.toContain(include); + expect(published[settingsFile]).not.toContain('projectDir'); + expect(published[rootFile]).toBe(rootBuild); + expect(published[appFile]).toBe(appBuild); + + await prebuild(local); + await prebuild({}); + expect(read()).toEqual(published); + + // A local build that links only the iOS package uses the published Android one. + await prebuild(local); + await prebuild({ + enableLocalDev: true, + localPath: { + ios: path.resolve(__dirname, '../../../../packages/apple'), + }, + }); + expect(read()).toEqual(published); + } finally { + fs.rmSync(projectRoot, {recursive: true, force: true}); + } + }, + ); + + it('writes the pin gradle.properties carries, and clears a stale one', () => { + // The pin is the only file the prebuild leaves behind that selects a store, + // so a stale key from an earlier prebuild would outrank the device. + const properties = [ + {type: 'property', key: 'org.gradle.jvmargs', value: '-Xmx2g'}, + {type: 'property', key: 'openiapStore', value: 'horizon'}, + // A leftover opt-out would make Gradle refuse the pin outright. + {type: 'property', key: 'openiapPlatform', value: 'none'}, + {type: 'property', key: 'horizonEnabled', value: 'true'}, + {type: 'property', key: 'fireOsEnabled', value: 'false'}, + ]; + expect(storeGradleProperties(properties, 'amazon')).toEqual([ + {type: 'property', key: 'org.gradle.jvmargs', value: '-Xmx2g'}, + {type: 'property', key: 'openiapStore', value: 'amazon'}, + ]); + expect(storeGradleProperties(properties, null)).toEqual([ + {type: 'property', key: 'org.gradle.jvmargs', value: '-Xmx2g'}, + ]); + }); + + it('reads the Amazon Appstore key path from android.amazon', () => { + expect( + resolveAmazonAppstoreKey({ + android: { + amazon: {appstoreKey: './keys/AppstoreAuthenticationKey.pem'}, + }, + }), + ).toBe('./keys/AppstoreAuthenticationKey.pem'); + expect(resolveAmazonAppstoreKey({})).toBeUndefined(); + }); + + it('removes a copied Amazon key once its source is gone', () => { + const fs = jest.requireActual('fs') as typeof import('fs'); + const os = jest.requireActual('os') as typeof import('os'); + const path = jest.requireActual('path') as typeof import('path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-iap-key-')); + const source = path.join(dir, 'AppstoreAuthenticationKey.pem'); + const target = path.join( + dir, + 'android', + 'assets', + 'AppstoreAuthenticationKey.pem', ); - expect(result).not.toContain('openiap-google-amazon:0.0.1'); - expect(result).toContain('missingDimensionStrategy "platform", "play"'); + try { + fs.writeFileSync(source, 'key'); + expect(syncAmazonAppstoreKey(source, target)).toBe(true); + expect(fs.readFileSync(target, 'utf8')).toBe('key'); + + // A stale copy would keep verifying with a key the config no longer finds. + fs.rmSync(source); + expect(syncAmazonAppstoreKey(source, target)).toBe(false); + expect(fs.existsSync(target)).toBe(false); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } }); it('normalizes Expo generated Groovy root Gradle syntax', () => { @@ -269,20 +490,23 @@ describe('android configuration', () => { }); }); - it('lets Fire OS take precedence over Horizon for Android flavor selection', () => { - expect( - resolveAmazonPlatformFlags({ - modules: { - horizon: true, - amazon: {fireOS: true, vegaOS: false}, - }, - }), - ).toEqual({ + it('reports Fire OS and Horizon as both set so the pin can refuse them', () => { + const flags = resolveAmazonPlatformFlags({ + modules: { + horizon: true, + amazon: {fireOS: true, vegaOS: false}, + }, + }); + + expect(flags).toEqual({ isFireOsEnabled: true, isVegaEnabled: false, - isHorizonEnabled: false, + isHorizonEnabled: true, isOnsideEnabled: false, }); + // Gradle and the doctor both fail this combination; silently preferring + // Fire OS here would ship a store the config never asked for. + expect(() => resolvePinnedAndroidStore(flags)).toThrow(/both enabled/); }); it('uses Expo IAP platform env flags when module options are absent', () => { @@ -302,7 +526,7 @@ describe('android configuration', () => { expect(resolveAmazonPlatformFlags(undefined)).toEqual({ isFireOsEnabled: true, isVegaEnabled: true, - isHorizonEnabled: false, + isHorizonEnabled: true, isOnsideEnabled: true, }); } finally { @@ -480,7 +704,7 @@ describe('android configuration', () => { ).toBeUndefined(); }); - it('removes Horizon App ID metadata outside Horizon builds', () => { + it('removes Horizon App ID metadata when no app id is configured', () => { const manifest = { manifest: { application: [ @@ -510,7 +734,7 @@ describe('android configuration', () => { }, }; - expect(syncHorizonAppIdMetaData(manifest, false, '123')).toBe('removed'); + expect(syncHorizonAppIdMetaData(manifest, undefined)).toBe('removed'); expect(manifest.manifest.application[0]!['meta-data']).toEqual([ { $: { @@ -527,13 +751,13 @@ describe('android configuration', () => { ]); }); - it('adds Horizon App ID metadata only for Horizon builds', () => { + it('adds Horizon App ID metadata whenever an app id is configured', () => { const manifest = {manifest: {}}; - expect(syncHorizonAppIdMetaData(manifest, false, '123')).toBe('unchanged'); + expect(syncHorizonAppIdMetaData(manifest, undefined)).toBe('unchanged'); expect(manifest.manifest).not.toHaveProperty('application'); - expect(syncHorizonAppIdMetaData(manifest, true, '123')).toBe('added'); + expect(syncHorizonAppIdMetaData(manifest, '123')).toBe('added'); expect(manifest.manifest.application?.[0]?.['meta-data']).toEqual([ { $: { @@ -568,7 +792,7 @@ describe('android configuration', () => { }, }; - expect(syncHorizonAppIdMetaData(manifest, true, '123')).toBe('added'); + expect(syncHorizonAppIdMetaData(manifest, '123')).toBe('added'); expect(manifest.manifest.application[0]!['meta-data']).toEqual([ { $: { @@ -607,7 +831,7 @@ describe('android configuration', () => { }, }; - expect(syncHorizonAppIdMetaData(manifest, true, '123')).toBe('added'); + expect(syncHorizonAppIdMetaData(manifest, '123')).toBe('added'); expect(manifest.manifest.application[0]!['meta-data']).toEqual([ { $: { @@ -673,7 +897,7 @@ describe('local OpenIAP configuration', () => { describe('ios module selection', () => { const createConfig = (ios?: ExpoConfig['ios']): ExpoConfig => - ({name: 'test-app', slug: 'test-app', ios}) as ExpoConfig; + ({name: 'test-app', slug: 'test-app', ios} as ExpoConfig); it('defaults to Expo IAP only when no options provided', () => { const result = resolveModuleSelection(createConfig(), undefined); diff --git a/libraries/expo-iap/plugin/src/__tests__/tsconfig.json b/libraries/expo-iap/plugin/src/__tests__/tsconfig.json index a5258a476..1e06ab648 100644 --- a/libraries/expo-iap/plugin/src/__tests__/tsconfig.json +++ b/libraries/expo-iap/plugin/src/__tests__/tsconfig.json @@ -1,8 +1,6 @@ -// Editor-only project for the Jest specs in this directory. The package -// tsconfig excludes __tests__ so expo-module-scripts never emits test files -// into build/, but that leaves VS Code without a project for them and every -// jest global errors with ts(2708). This config re-attaches the directory to -// the package compiler options; ts-jest still type-checks specs at test time. +// Editor-only: gives VS Code a project for these specs, which the package +// tsconfig excludes so expo-module-scripts keeps them out of build/. Without it, +// every Jest global errors with ts(2708). ts-jest type-checks specs at test time. { "extends": "../../tsconfig.json", "compilerOptions": { diff --git a/libraries/expo-iap/plugin/src/__tests__/withLocalOpenIAP.test.ts b/libraries/expo-iap/plugin/src/__tests__/withLocalOpenIAP.test.ts index 398e3684e..ca7503812 100644 --- a/libraries/expo-iap/plugin/src/__tests__/withLocalOpenIAP.test.ts +++ b/libraries/expo-iap/plugin/src/__tests__/withLocalOpenIAP.test.ts @@ -1,6 +1,17 @@ -import {ensureLocalOpenIapFlavorStrategy} from '../withLocalOpenIAP'; +import { + appStoreLines, + ensureLocalOpenIapFlavorStrategy, + LOCAL_STRATEGY_LINE_GROOVY, + LOCAL_STRATEGY_LINE_KOTLIN, + removeLocalOpenIapAppWiring, + removeLocalOpenIapFlavorStrategy, + removeLocalOpenIapSettings, + setLocalOpenIapPodPath, + storeScriptPathFrom, +} from '../withLocalOpenIAP'; describe('ensureLocalOpenIapFlavorStrategy', () => { + const scriptPath = '../node_modules/expo-iap/android/openiap-store.gradle'; const baseProjectBuildGradle = [ '// Top-level build file where you can add configuration options common to all sub-projects/modules.', '', @@ -13,17 +24,25 @@ describe('ensureLocalOpenIapFlavorStrategy', () => { '', ].join('\n'); - it('adds a default platform flavor for Android library subprojects', () => { + it('applies the resolver once and shares the resolved store', () => { const result = ensureLocalOpenIapFlavorStrategy( baseProjectBuildGradle, - 'play', + scriptPath, ); + expect(result).toContain(`apply from: "${scriptPath}"`); + expect(result).toContain( + 'def openIapStore = openIapResolveStore("expo-iap").store', + ); expect(result).toContain('subprojects { subproject ->'); + // The app module is an application, not a library, and needs the strategy too. expect(result).toContain( - 'subproject.plugins.withId("com.android.library")', + '["com.android.library", "com.android.application"].each { pluginId ->', + ); + expect(result).toContain(LOCAL_STRATEGY_LINE_GROOVY.trim()); + expect(result).not.toMatch( + /missingDimensionStrategy "platform", "(play|horizon|amazon)"/, ); - expect(result).toContain('missingDimensionStrategy "platform", "play"'); expect(result).toMatch( /project\(":openiap-google"\)\s*\{\s*layout\.buildDirectory\.set\(rootProject\.layout\.buildDirectory\.dir\("openiap-google"\)\)\s*\}/, ); @@ -32,43 +51,144 @@ describe('ensureLocalOpenIapFlavorStrategy', () => { it('emits Kotlin DSL for Kotlin project build files', () => { const result = ensureLocalOpenIapFlavorStrategy( baseProjectBuildGradle, - 'horizon', - 'kotlin', + scriptPath, + 'kt', ); - expect(result).toContain('subprojects {'); + expect(result).toContain(`apply(from = "${scriptPath}")`); + expect(result).toContain('val openIapStore ='); expect(result).toContain( - 'extensions.configure("android")', - ); - expect(result).toContain('missingDimensionStrategy("platform", "horizon")'); - expect(result).toMatch( - /project\(":openiap-google"\)\s*\{\s*layout\.buildDirectory\.set\(rootProject\.layout\.buildDirectory\.dir\("openiap-google"\)\)\s*\}/, - ); - expect(result).not.toContain( - 'missingDimensionStrategy "platform", "horizon"', + 'listOf("com.android.library", "com.android.application").forEach', ); + expect(result).toContain(LOCAL_STRATEGY_LINE_KOTLIN.trim()); + expect(result).not.toContain(LOCAL_STRATEGY_LINE_GROOVY.trim()); }); - it('replaces the managed block when the target flavor changes', () => { - const playResult = ensureLocalOpenIapFlavorStrategy( + it('replaces the managed block instead of stacking copies', () => { + const first = ensureLocalOpenIapFlavorStrategy( baseProjectBuildGradle, - 'play', + scriptPath, ); - const horizonResult = ensureLocalOpenIapFlavorStrategy( - `${playResult}\n${playResult}`, - 'horizon', + const second = ensureLocalOpenIapFlavorStrategy( + `${first}\n${first}`, + scriptPath, ); - expect(horizonResult).toContain( - 'missingDimensionStrategy "platform", "horizon"', - ); - expect(horizonResult).not.toContain( - 'missingDimensionStrategy "platform", "play"', - ); expect( - horizonResult.match( + second.match( /Added by expo-iap \(local openiap-google flavor selection\)/g, ) ?? [], ).toHaveLength(1); + expect(second.match(/openIapResolveStore/g) ?? []).toHaveLength(1); + }); + + it('gives the app its own apply and resolver call', () => { + // `:app` evaluates before the root build file, so it cannot read a value + // the root computed. + const groovy = appStoreLines('../x/openiap-store.gradle', 'groovy'); + expect(groovy.apply).toBe('apply from: "../x/openiap-store.gradle"'); + expect(groovy.strategy).toContain('openIapResolveStore("app").store'); + expect(groovy.strategy).not.toContain('rootProject'); + + const kotlin = appStoreLines('../x/openiap-store.gradle', 'kt'); + expect(kotlin.apply).toContain('apply(from = "../x/openiap-store.gradle")'); + expect(kotlin.apply).toContain('val openIapStore ='); + expect(kotlin.apply).toContain('openIapResolveStore'); + expect(kotlin.strategy).toContain('openIapStore'); + expect(kotlin.strategy).not.toContain('openIapResolveStore'); + expect(kotlin.strategy).not.toContain('rootProject'); + }); + + it('removes the Kotlin DSL wiring a local build wrote', () => { + const {apply, strategy} = appStoreLines('../x/openiap-store.gradle', 'kt'); + const app = [ + 'plugins {', + ' id("com.android.application")', + '}', + '', + 'android {', + ' defaultConfig {', + ' }', + '}', + '', + 'dependencies {', + ' implementation("com.facebook.react:react-android")', + '}', + '', + ].join('\n'); + const localApp = app + .replace('android {', `${apply}\n\nandroid {`) + .replace('defaultConfig {', `defaultConfig {\n${strategy}`) + .replace( + 'dependencies {', + 'dependencies {\n implementation(project(":openiap-google"))', + ); + expect(removeLocalOpenIapAppWiring(localApp)).toBe(app); + + const settings = 'rootProject.name = "app"\ninclude(":app")\n'; + const localSettings = `${settings}\ninclude(":openiap-google")\nproject(":openiap-google").projectDir = File(settingsDir, "../x")\n`; + expect(removeLocalOpenIapSettings(localSettings)).toBe(settings); + + expect( + removeLocalOpenIapFlavorStrategy( + ensureLocalOpenIapFlavorStrategy( + baseProjectBuildGradle, + scriptPath, + 'kt', + ), + ), + ).toBe(baseProjectBuildGradle); + }); + + it('removes the Groovy wiring and keeps the next line intact', () => { + const {apply, strategy} = appStoreLines( + '../x/openiap-store.gradle', + 'groovy', + ); + const app = [ + 'android {', + ' defaultConfig {', + ' }', + '}', + '', + 'dependencies {', + ' // React Native sets this version', + ' implementation("com.facebook.react:react-android")', + '}', + '', + ].join('\n'); + const localApp = app + .replace('android {', `${apply}\n\nandroid {`) + .replace('defaultConfig {', `defaultConfig {\n${strategy}`) + .replace( + 'dependencies {', + "dependencies {\n implementation project(':openiap-google')", + ); + expect(removeLocalOpenIapAppWiring(localApp)).toBe(app); + }); + + it('keeps build lines the local build did not write', () => { + const app = + 'android {\n defaultConfig {\n missingDimensionStrategy "env", "prod"\n }\n}\ndependencies {\n implementation project(":feature")\n}\n'; + expect(removeLocalOpenIapAppWiring(app)).toBe(app); + const settings = "include ':app'\ninclude ':feature'\n"; + expect(removeLocalOpenIapSettings(settings)).toBe(settings); + }); + + it('moves the local pod when localPath moves', () => { + const podfile = + "target 'App' do\n use_expo_modules!\n\n pod 'openiap', :path => '../../old/apple'\nend\n"; + expect(setLocalOpenIapPodPath(podfile, '../../new/apple')).toBe( + podfile.replace('../../old/apple', '../../new/apple'), + ); + // A versioned pod is the app's own choice. + const versioned = " pod 'openiap', '~> 1.3'\n"; + expect(setLocalOpenIapPodPath(versioned, '../x')).toBe(versioned); + }); + + it('points at the resolver that ships beside this plugin', () => { + const relative = storeScriptPathFrom('/tmp/app/android'); + expect(relative).toMatch(/android\/openiap-store\.gradle$/); + expect(relative).not.toContain('\\'); }); }); diff --git a/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts b/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts index cbea259b9..34cac490b 100644 --- a/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts +++ b/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts @@ -10,9 +10,10 @@ export type ExpoIapModuleOverrides = { */ onside?: boolean; /** - * Enable Horizon OS support for Meta Quest devices + * @deprecated A debug build follows the connected Quest. Pin EAS and release + * builds with `ORG_GRADLE_PROJECT_openiapStore=horizon` in the profile's `env`. + * Still pins, with a warning. * @platform android - * @default false */ horizon?: boolean; /** @@ -24,10 +25,10 @@ export type ExpoIapModuleOverrides = { export type AmazonPlatformOptions = { /** - * Enable Fire OS support for Amazon-distributed Android builds. - * This selects the Android `amazon` flavor. + * @deprecated A debug build follows the connected Fire device. Pin EAS and + * release builds with `ORG_GRADLE_PROJECT_openiapStore=amazon` in the + * profile's `env`. Still pins, with a warning. * @platform android - * @default false */ fireOS?: boolean; /** @@ -76,6 +77,12 @@ type BaseExpoIapOptions = { * modules.amazon; this object only contains per-target settings. */ amazon?: { + /** + * Path to the Amazon Appstore public key (`AppstoreAuthenticationKey.pem`), + * relative to the project root. Copied into the app's assets on every + * prebuild; Fire OS needs it to verify receipts. + */ + appstoreKey?: string; /** * Vega OS project generation overrides used when modules.amazon.vegaOS is true. * packageId defaults to android.package, title defaults to expo.name, @@ -97,7 +104,8 @@ type ExplicitModuleOptions = BaseExpoIapOptions & { }; export type ExpoIapPluginCommonOptions = - AutoModuleOptions | ExplicitModuleOptions; + | AutoModuleOptions + | ExplicitModuleOptions; declare module '@expo/config-types' { interface IOS { diff --git a/libraries/expo-iap/plugin/src/withIAP.ts b/libraries/expo-iap/plugin/src/withIAP.ts index 84fde64f5..20e62e6b7 100644 --- a/libraries/expo-iap/plugin/src/withIAP.ts +++ b/libraries/expo-iap/plugin/src/withIAP.ts @@ -4,6 +4,7 @@ import { WarningAggregator, withAndroidManifest, withAppBuildGradle, + withDangerousMod, withGradleProperties, withInfoPlist, withPodfile, @@ -12,7 +13,7 @@ import { import type {ExpoConfig} from '@expo/config-types'; import * as fs from 'fs'; import * as path from 'path'; -import withLocalOpenIAP from './withLocalOpenIAP'; +import withLocalOpenIAP, {withoutLocalOpenIAPAndroid} from './withLocalOpenIAP'; import withVega, {type VegaProjectOptions} from './withVega'; import { withIosAlternativeBilling, @@ -40,26 +41,6 @@ const logOnce = (() => { }; })(); -const addLineToGradle = ( - content: string, - anchor: RegExp | string, - lineToAdd: string, - offset: number = 1, -): string => { - const lines = content.split('\n'); - const index = lines.findIndex((line) => line.match(anchor)); - if (index === -1) { - WarningAggregator.addWarningAndroid( - 'expo-iap', - `dependencies { ... } block not found; skipping injection: ${lineToAdd.trim()}`, - ); - return content; - } else { - lines.splice(index + offset, 0, lineToAdd); - } - return lines.join('\n'); -}; - const HORIZON_APP_ID_META_DATA_NAME = 'com.meta.horizon.platform.HORIZON_APP_ID'; @@ -144,9 +125,9 @@ export const normalizeGeneratedGroovyAppBuildGradle = ( return modified; }; +// The app id is inert outside Quest, so every build carries it. export function syncHorizonAppIdMetaData( manifest: AndroidManifestLike, - isHorizonEnabled?: boolean, horizonAppId?: string, ): HorizonAppIdSyncResult { const application = manifest.manifest.application?.[0]; @@ -155,7 +136,7 @@ export function syncHorizonAppIdMetaData( } const existingMetaData = application?.['meta-data']; - if (!isHorizonEnabled) { + if (!horizonAppId) { if (!Array.isArray(existingMetaData)) return 'unchanged'; const nextMetaData = existingMetaData.filter( @@ -169,8 +150,6 @@ export function syncHorizonAppIdMetaData( return 'removed'; } - if (!horizonAppId) return 'unchanged'; - if ( !manifest.manifest.application || manifest.manifest.application.length === 0 @@ -199,138 +178,143 @@ export function syncHorizonAppIdMetaData( return hadExistingAppId ? 'updated' : 'added'; } +const OPENIAP_DEPENDENCY_LINE = + /^\s*(?:implementation|api)\s*\(?\s*["']io\.github\.hyochan\.openiap:openiap-google(?:-(?:horizon|amazon))?:[^"']+["']\s*\)?\s*$/gm; +const PLATFORM_STRATEGY_LINE = + /^\s*missingDimensionStrategy\s*\(?\s*["']platform["']\s*,\s*["'](play|horizon|amazon)["']\s*\)?\s*$/gm; + +// The expo-iap module owns the OpenIAP dependency and the store choice, so the +// app build file carries neither; a copy an older plugin wrote is removed. export const modifyAppBuildGradle = ( gradle: string, - language: 'groovy' | 'kotlin', - isHorizonEnabled?: boolean, - isFireOsEnabled?: boolean, + language: 'groovy' | 'kt', ): string => { - function loadOpenIapAndroidVersion(): string { - try { - const parsed = require('../../openiap-versions.json'); - const googleVersion = - typeof parsed?.google === 'string' ? parsed.google.trim() : ''; - if (!googleVersion) { - throw new Error( - 'expo-iap: "google" version missing or invalid in openiap-versions.json', - ); - } - return googleVersion; - } catch (error) { - throw new Error( - `expo-iap: Unable to load openiap-versions.json (${ - error instanceof Error ? error.message : error - })`, - ); - } - } - let modified = language === 'groovy' ? normalizeGeneratedGroovyAppBuildGradle(gradle) : gradle; - let openIapAndroidVersion: string; - try { - openIapAndroidVersion = loadOpenIapAndroidVersion(); - } catch (error) { - WarningAggregator.addWarningAndroid( - 'expo-iap', - `expo-iap: Failed to resolve OpenIAP version (${ - error instanceof Error ? error.message : error - })`, + const withoutDependency = modified.replace(OPENIAP_DEPENDENCY_LINE, ''); + if (withoutDependency !== modified) { + modified = withoutDependency.replace(/\n{3,}/g, '\n\n'); + logOnce( + '🧹 expo-iap: Removed the OpenIAP dependency from app build.gradle; the module provides it', ); - return gradle; } - let flavor: 'amazon' | 'horizon' | 'play' = 'play'; - let artifactId: - 'openiap-google-amazon' | 'openiap-google-horizon' | 'openiap-google' = - 'openiap-google'; - if (isFireOsEnabled) { - flavor = 'amazon'; - artifactId = 'openiap-google-amazon'; - } else if (isHorizonEnabled) { - flavor = 'horizon'; - artifactId = 'openiap-google-horizon'; + const removedStrategies: string[] = []; + const withoutStrategy = modified.replace(PLATFORM_STRATEGY_LINE, (line) => { + removedStrategies.push(line.trim()); + return ''; + }); + if (withoutStrategy !== modified) { + modified = withoutStrategy; + logOnce( + `🧹 expo-iap: Removed fixed platform strategies (${removedStrategies.join('; ')}) — the store is resolved at build time; pin it with openiapStore instead`, + ); } - // Ensure OpenIAP dependency exists at desired version in app-level build.gradle(.kts) - const impl = (ga: string, v: string) => - language === 'kotlin' - ? ` implementation("${ga}:${v}")` - : ` implementation "${ga}:${v}"`; - const openiapDep = impl( - `io.github.hyochan.openiap:${artifactId}`, - openIapAndroidVersion, - ); - - // Remove any existing openiap-google flavor lines (any version, groovy/kotlin, implementation/api) - const openiapAnyLine = - /^\s*(?:implementation|api)\s*\(?\s*["']io\.github\.hyochan\.openiap:openiap-google(?:-(?:horizon|amazon))?:[^"']+["']\s*\)?\s*$/gm; - const withoutExistingOpeniap = modified.replace(openiapAnyLine, ''); - const hadExisting = withoutExistingOpeniap !== modified; - if (hadExisting) { - modified = withoutExistingOpeniap.replace(/\n{3,}/g, '\n\n'); - } + return modified; +}; - // Ensure the desired dependency line is present - if ( - !new RegExp( - String.raw`io\.github\.hyochan\.openiap:${artifactId}:${openIapAndroidVersion}`, - ).test(modified) - ) { - // Insert just after the opening `dependencies {` line - modified = addLineToGradle(modified, /dependencies\s*{/, openiapDep, 1); +export type AndroidStorePin = 'horizon' | 'amazon' | null; + +const STORE_PROPERTY_KEYS = [ + 'openiapStore', + 'openiapPlatform', + 'horizonEnabled', + 'fireOsEnabled', +]; + +type GradleProperty = {type: string; key?: string; value?: string}; + +// A pin outranks everything else, so a key an earlier prebuild left is removed. +export function storeGradleProperties( + properties: T[], + pinnedStore: AndroidStorePin, +): T[] { + const kept = properties.filter( + (item) => + item.type !== 'property' || !STORE_PROPERTY_KEYS.includes(item.key ?? ''), + ); + const removed = properties + .filter( + (item) => + item.type === 'property' && + STORE_PROPERTY_KEYS.includes(item.key ?? ''), + ) + .map((item) => `${item.key}=${item.value ?? ''}`); + const netRemoved = pinnedStore + ? removed.filter((entry) => entry !== `openiapStore=${pinnedStore}`) + : removed; + if (netRemoved.length > 0) { + const suffix = pinnedStore + ? `re-pinned openiapStore=${pinnedStore}` + : 'store now resolves automatically'; logOnce( - hadExisting - ? `🛠️ expo-iap: Replaced OpenIAP dependency with ${openIapAndroidVersion}` - : `🛠️ expo-iap: Added OpenIAP dependency (${openIapAndroidVersion}) to build.gradle`, + `🧹 expo-iap: Removed stale store properties (${netRemoved.join(', ')}) — ${suffix}`, ); } + return pinnedStore + ? [ + ...kept, + {type: 'property', key: 'openiapStore', value: pinnedStore} as T, + ] + : kept; +} +export const AMAZON_APPSTORE_KEY_FILE = 'AppstoreAuthenticationKey.pem'; - // Remove stale OpenIAP platform strategies even when returning to the default - // Play artifact. Otherwise a previous Fire OS/Horizon prebuild can keep - // selecting the wrong local flavor. - const strategyPattern = - /^\s*missingDimensionStrategy\s*\(?\s*["']platform["']\s*,\s*["'](play|horizon|amazon)["']\s*\)?\s*$/gm; - const withoutExistingStrategy = modified.replace(strategyPattern, ''); - if (withoutExistingStrategy !== modified) { - modified = withoutExistingStrategy; - logOnce('🧹 Removed existing missingDimensionStrategy for platform'); +// Copies the key into the app, or removes the old copy when the source is gone. +export function syncAmazonAppstoreKey(source: string, target: string): boolean { + if (!fs.existsSync(source)) { + fs.rmSync(target, {force: true}); + return false; } + fs.mkdirSync(path.dirname(target), {recursive: true}); + fs.copyFileSync(source, target); + return true; +} - const defaultConfigRegex = /defaultConfig\s*{/; - if (defaultConfigRegex.test(modified)) { - const strategyLine = - language === 'kotlin' - ? ` missingDimensionStrategy("platform", "${flavor}")` - : ` missingDimensionStrategy "platform", "${flavor}"`; - - // Add the new strategy - if (!/missingDimensionStrategy.*platform/.test(modified)) { - modified = addLineToGradle(modified, defaultConfigRegex, strategyLine, 1); +// Amazon reads the key from assets to verify receipts; it is inert elsewhere. +const withAmazonAppstoreKey: ConfigPlugin = (config, keyPath) => + withDangerousMod(config, [ + 'android', + async (config) => { + const {projectRoot, platformProjectRoot} = config.modRequest; + const source = path.resolve(projectRoot, keyPath); + const target = path.join( + platformProjectRoot, + 'app', + 'src', + 'main', + 'assets', + AMAZON_APPSTORE_KEY_FILE, + ); + if (!syncAmazonAppstoreKey(source, target)) { + WarningAggregator.addWarningAndroid( + 'expo-iap', + `Amazon Appstore key not found at ${source}; Fire OS builds cannot verify receipts without it.`, + ); + return config; + } logOnce( - `🛠️ expo-iap: Added missingDimensionStrategy for ${flavor} flavor`, + `✅ expo-iap: Copied ${AMAZON_APPSTORE_KEY_FILE} into android/app/src/main/assets`, ); - } - } - - return modified; -}; + return config; + }, + ]); const withIapAndroid: ConfigPlugin< { - addDeps?: boolean; horizonAppId?: string; - isHorizonEnabled?: boolean; - isFireOsEnabled?: boolean; + pinnedStore?: AndroidStorePin; + amazonAppstoreKey?: string; } | void > = (config, props) => { - const addDeps = props?.addDeps ?? true; + const pinnedStore = props?.pinnedStore ?? null; config = withProjectBuildGradle(config, (config) => { - const language = (config.modResults as any).language || 'groovy'; + const {language} = config.modResults; if (language === 'groovy') { config.modResults.contents = normalizeGeneratedGroovyProjectBuildGradle( config.modResults.contents, @@ -340,54 +324,24 @@ const withIapAndroid: ConfigPlugin< }); config = withAppBuildGradle(config, (config) => { - const language = (config.modResults as any).language || 'groovy'; - const normalized = - language === 'groovy' - ? normalizeGeneratedGroovyAppBuildGradle(config.modResults.contents) - : config.modResults.contents; - - config.modResults.contents = addDeps - ? modifyAppBuildGradle( - normalized, - language, - props?.isHorizonEnabled, - props?.isFireOsEnabled, - ) - : normalized; - + const {language} = config.modResults; + config.modResults.contents = modifyAppBuildGradle( + config.modResults.contents, + language, + ); return config; }); - // Set store flags in gradle.properties so expo-iap module can pick them up. config = withGradleProperties(config, (config) => { - const horizonValue = props?.isHorizonEnabled ?? false; - const fireOsValue = props?.isFireOsEnabled ?? false; - - config.modResults = config.modResults.filter( - (item) => - item.type !== 'property' || - !['horizonEnabled', 'fireOsEnabled'].includes(item.key), + config.modResults = storeGradleProperties(config.modResults, pinnedStore); + logOnce( + pinnedStore + ? `✅ expo-iap: Set openiapStore=${pinnedStore} in gradle.properties` + : 'ℹ️ expo-iap: No store pin; Gradle picks the store from the task flavor or the connected debug device', ); - - config.modResults.push({ - type: 'property', - key: 'horizonEnabled', - value: String(horizonValue), - }); - config.modResults.push({ - type: 'property', - key: 'fireOsEnabled', - value: String(fireOsValue), - }); - - logOnce(`✅ Set horizonEnabled=${horizonValue} in gradle.properties`); - logOnce(`✅ Set fireOsEnabled=${fireOsValue} in gradle.properties`); - return config; }); - // Note: missingDimensionStrategy for local dev is handled in withLocalOpenIAP - config = withAndroidManifest(config, (config) => { const manifest = config.modResults; const existingPermissions = manifest.manifest['uses-permission']; @@ -400,7 +354,7 @@ const withIapAndroid: ConfigPlugin< manifest.manifest['uses-permission'] = permissions; const billingPerm = {$: {'android:name': 'com.android.vending.BILLING'}}; - if (props?.isFireOsEnabled) { + if (pinnedStore === 'amazon') { const nextPermissions = permissions.filter( (p) => p.$['android:name'] !== 'com.android.vending.BILLING', ); @@ -426,7 +380,6 @@ const withIapAndroid: ConfigPlugin< const horizonAppIdSync = syncHorizonAppIdMetaData( manifest, - props?.isHorizonEnabled, props?.horizonAppId, ); if (horizonAppIdSync === 'removed') { @@ -446,6 +399,10 @@ const withIapAndroid: ConfigPlugin< return config; }); + if (props?.amazonAppstoreKey) { + config = withAmazonAppstoreKey(config, props.amazonAppstoreKey); + } + return config; }; @@ -765,11 +722,10 @@ export function resolveAmazonPlatformFlags( ? moduleAmazon?.vegaOS === true : isEnvFlagEnabled('EXPO_IAP_VEGA'); const modules = options?.modules; - const isHorizonEnabled = isFireOsEnabled - ? false - : hasOwnKey(modules, 'horizon') - ? modules?.horizon === true - : isEnvFlagEnabled('EXPO_IAP_HORIZON'); + // Both flags are reported so resolvePinnedAndroidStore can refuse the pair. + const isHorizonEnabled = hasOwnKey(modules, 'horizon') + ? modules?.horizon === true + : isEnvFlagEnabled('EXPO_IAP_HORIZON'); const isOnsideEnabled = hasOwnKey(modules, 'onside') ? modules?.onside === true : isEnvFlagEnabled('EXPO_IAP_ONSIDE'); @@ -788,6 +744,44 @@ export function resolveHorizonAppId( return options?.android?.horizon?.appId ?? undefined; } +export function resolveAmazonAppstoreKey( + options?: ExpoIapPluginOptions | void, +): string | undefined { + return options?.android?.amazon?.appstoreKey ?? undefined; +} + +// A module flag pins the store for every build; without one, Gradle picks it. +// The flags are deprecated but still pin, so a Quest or Fire release keeps its store. +export function resolvePinnedAndroidStore( + flags: Pick, +): AndroidStorePin { + // An APK links one billing SDK. + if (flags.isFireOsEnabled && flags.isHorizonEnabled) { + throw new Error( + 'expo-iap: modules.amazon.fireOS and modules.horizon are both enabled; ' + + 'an Android build links one store, so enable one of them.', + ); + } + return flags.isFireOsEnabled + ? 'amazon' + : flags.isHorizonEnabled + ? 'horizon' + : null; +} + +export function deprecatedStorePinWarning(store: 'horizon' | 'amazon'): string { + const key = + store === 'horizon' + ? 'modules.horizon (or EXPO_IAP_HORIZON)' + : 'modules.amazon.fireOS (or EXPO_IAP_FIREOS)'; + const device = store === 'horizon' ? 'Quest' : 'Fire device'; + return ( + `${key} is deprecated: a local debug build already follows the connected ${device}. ` + + `Pin every EAS or release build that must target it with ORG_GRADLE_PROJECT_openiapStore=${store} ` + + `in the build profile env; until then ${key} still pins every build of this prebuild.` + ); +} + export function resolveAlternativeBillingIOS( options?: ExpoIapPluginOptions | void, ): IOSAlternativeBillingConfig | undefined { @@ -803,16 +797,16 @@ export function resolveVegaProjectOptions( } /** - * Determines which modules to include based on configuration. - * - ExpoIap: Always included (standard StoreKit 2 support) - * - Onside: Only when modules.onside is true (iOS alternative billing) + * Determines which native modules to include: ExpoIap (StoreKit 2) and/or + * Onside (iOS alternative billing). */ export function resolveModuleSelection( config: ExpoConfig, options?: ExpoIapPluginCommonOptions | void, ): ModuleSelectionResult { const normalizedOptions = (options ?? undefined) as - ExpoIapPluginCommonOptions | undefined; + | ExpoIapPluginCommonOptions + | undefined; const selection = normalizedOptions?.module ?? 'auto'; @@ -847,6 +841,11 @@ const withIap: ConfigPlugin = ( ) => { const {isFireOsEnabled, isVegaEnabled, isHorizonEnabled, isOnsideEnabled} = resolveAmazonPlatformFlags(options); + // Outside the try, whose catch would turn this error into a warning. + const pinnedStore = resolvePinnedAndroidStore({ + isFireOsEnabled, + isHorizonEnabled, + }); try { // Add iapkitApiKey to extra if provided @@ -859,10 +858,21 @@ const withIap: ConfigPlugin = ( } const horizonAppId = resolveHorizonAppId(options); + const amazonAppstoreKey = resolveAmazonAppstoreKey(options); + if (pinnedStore) { + WarningAggregator.addWarningAndroid( + 'expo-iap', + deprecatedStorePinWarning(pinnedStore), + ); + } const iosAlternativeBilling = resolveAlternativeBillingIOS(options); logOnce( - `🔍 [expo-iap] Config values: horizonAppId=${horizonAppId}, isHorizonEnabled=${isHorizonEnabled}, isFireOsEnabled=${isFireOsEnabled}, isVegaEnabled=${isVegaEnabled}, isOnsideEnabled=${isOnsideEnabled}`, + `🔍 [expo-iap] Config values: horizonAppId=${horizonAppId}, pinnedStore=${ + pinnedStore ?? 'auto' + }, amazonAppstoreKey=${ + amazonAppstoreKey ?? 'none' + }, isVegaEnabled=${isVegaEnabled}, isOnsideEnabled=${isOnsideEnabled}`, ); const {includeExpoIap, includeOnside} = resolveModuleSelection( @@ -889,56 +899,53 @@ const withIap: ConfigPlugin = ( // Respect explicit flag; fall back to presence of localPath only when flag is unset const isLocalDev = options?.enableLocalDev ?? !!options?.localPath; - // Apply Android modifications (skip adding deps when linking local module) let result = withIapAndroid(config, { - addDeps: !isLocalDev, horizonAppId, - isHorizonEnabled, - isFireOsEnabled, + pinnedStore, + amazonAppstoreKey, }); - // iOS: choose one path to avoid overlap - if (isLocalDev) { - if (!options?.localPath) { - WarningAggregator.addWarningIOS( - 'expo-iap', - 'enableLocalDev is true but no localPath provided. Skipping local OpenIAP integration.', - ); - } else { - const raw = options.localPath; - const resolved = - typeof raw === 'string' - ? path.resolve(raw) - : { - ios: raw.ios ? path.resolve(raw.ios) : undefined, - android: raw.android ? path.resolve(raw.android) : undefined, - }; - - const preview = - typeof resolved === 'string' - ? resolved - : `ios=${resolved.ios ?? 'auto'}, android=${ - resolved.android ?? 'auto' - }`; - logOnce(`🔧 [expo-iap] Enabling local OpenIAP: ${preview}`); - if (includeOnside) { - result = withOnsideInfoPlist(result); - } - result = withLocalOpenIAP(result, { - localPath: resolved, - iosAlternativeBilling, - horizonAppId, - isHorizonEnabled, - isFireOsEnabled, - enableOnside: includeOnside, - }); + // One path per prebuild: the local checkout, or the published packages. + const localPath = isLocalDev ? options?.localPath : undefined; + if (isLocalDev && !localPath) { + WarningAggregator.addWarningIOS( + 'expo-iap', + 'enableLocalDev is true but no localPath provided. Using the published OpenIAP instead.', + ); + } + if (localPath) { + const resolved = + typeof localPath === 'string' + ? path.resolve(localPath) + : { + ios: localPath.ios ? path.resolve(localPath.ios) : undefined, + android: localPath.android + ? path.resolve(localPath.android) + : undefined, + }; + + const preview = + typeof resolved === 'string' + ? resolved + : `ios=${resolved.ios ?? 'auto'}, android=${ + resolved.android ?? 'auto' + }`; + logOnce(`🔧 [expo-iap] Enabling local OpenIAP: ${preview}`); + if (includeOnside) { + result = withOnsideInfoPlist(result); } + result = withLocalOpenIAP(result, { + localPath: resolved, + iosAlternativeBilling, + enableOnside: includeOnside, + }); } else { // Ensure iOS Podfile is set up to resolve public CocoaPods specs result = withIapIOS(result, { enableOnside: includeOnside, iosAlternativeBilling, }); + result = withoutLocalOpenIAPAndroid(result); if (includeExpoIap) { logOnce('📦 [expo-iap] Using OpenIAP from CocoaPods'); } diff --git a/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts b/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts index e619dd8fe..4be1deaf8 100644 --- a/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts +++ b/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts @@ -1,5 +1,6 @@ import { ConfigPlugin, + WarningAggregator, withDangerousMod, withSettingsGradle, withAppBuildGradle, @@ -13,13 +14,10 @@ import { } from './withIosAlternativeBilling'; import {ensureOnsidePodIOS} from './onsidePodfile'; -/** - * Plugin to add local OpenIAP pod dependency for development - * This is only for local development with openiap-apple library - */ +/** Adds the local OpenIAP pod dependency; for local openiap-apple development only. */ export type LocalPathOption = string | {ios?: string; android?: string}; -type GradleLanguage = 'groovy' | 'kotlin'; -type OpenIapAndroidFlavor = 'play' | 'horizon' | 'amazon'; +// Expo's names for a .gradle and a .gradle.kts file. +type GradleLanguage = 'groovy' | 'kt'; export const getAndroidLocalPathInput = ( raw?: LocalPathOption, @@ -106,49 +104,140 @@ const LOCAL_OPENIAP_FLAVOR_BLOCK_START = const LOCAL_OPENIAP_FLAVOR_BLOCK_END = '// End expo-iap local openiap-google flavor selection'; -const normalizeGradleLanguage = (language?: string): GradleLanguage => - language === 'kotlin' ? 'kotlin' : 'groovy'; +// The resolver ships beside this plugin, wherever node_modules put it. +const OPENIAP_STORE_SCRIPT = path.resolve( + __dirname, + '../../android/openiap-store.gradle', +); + +export const storeScriptPathFrom = (platformProjectRoot: string): string => + path + .relative(platformProjectRoot, OPENIAP_STORE_SCRIPT) + .split(path.sep) + .join('/'); + +// Each module applies the resolver itself; it caches its answer, so all agree. +// `:app` cannot read a root value: React Native evaluates it before the root script. +export const LOCAL_STRATEGY_LINE_GROOVY = + ' missingDimensionStrategy "platform", openIapStore'; +export const LOCAL_STRATEGY_LINE_KOTLIN = + ' missingDimensionStrategy("platform", openIapStore)'; + +export const appStoreLines = ( + storeScriptPath: string, + language: GradleLanguage, +): {apply: string; strategy: string} => + language === 'kt' + ? { + // defaultConfig's receiver is DefaultConfig, not the script, so read + // extra at the top level where the script scope applies. + apply: `apply(from = "${storeScriptPath}")\nval openIapStore = ((extra["openIapResolveStore"] as groovy.lang.Closure<*>).call("app") as Map<*, *>)["store"] as String`, + strategy: ' missingDimensionStrategy("platform", openIapStore)', + } + : { + apply: `apply from: "${storeScriptPath}"`, + strategy: + ' missingDimensionStrategy "platform", openIapResolveStore("app").store', + }; + +// Each removal also takes the blank line written beside the line, so a switch +// back to published leaves the file as it was. +export const removeLocalOpenIapFlavorStrategy = (contents: string): string => + contents.replace( + new RegExp( + `(?:^[ \\t]*\\n)?${escapeRegExp( + LOCAL_OPENIAP_FLAVOR_BLOCK_START, + )}[\\s\\S]*?${escapeRegExp(LOCAL_OPENIAP_FLAVOR_BLOCK_END)}\\n?`, + 'gm', + ), + '', + ); + +// expo-iap links an included :openiap-google over Maven, so a published build +// drops what a local one wrote. +export const removeLocalOpenIapSettings = (contents: string): string => + contents + .replace( + /(?:^[ \t]*\n)?^[ \t]*include[ \t]*\(?[ \t]*["']:openiap-google["'][ \t]*\)?[ \t]*\n?/gm, + '', + ) + .replace( + /^[ \t]*project\(["']:openiap-google["']\)\.projectDir[ \t]*=.*\n?/gm, + '', + ); + +export const removeLocalOpenIapAppWiring = (contents: string): string => + contents + .replace( + /^[ \t]*implementation[ \t]*\(?[ \t]*project\([ \t]*["']:openiap-google["'][ \t]*\)[ \t]*\)?[ \t]*\n?/gm, + '', + ) + .replace( + /^[ \t]*apply[ \t]*(?:from:|\(from = )[ \t]*"[^"]*openiap-store\.gradle"\)?[ \t]*\n?(?:^[ \t]*\n)?/gm, + '', + ) + .replace( + /^[ \t]*val openIapStore = .*openIapResolveStore.*\n?(?:^[ \t]*\n)?/gm, + '', + ) + .replace( + /^[ \t]*missingDimensionStrategy[\s(]{0,4}["']platform["'][^\n]*(openIapResolveStore|openIapStore)[^\n]*\n?/gm, + '', + ); + +// A localPath that moved must move the pod with it. +export const setLocalOpenIapPodPath = ( + podfile: string, + relativePath: string, +): string => + podfile.replace( + /(pod\s+'openiap'\s*,\s*:path\s*=>\s*)(['"])[^'"\n]*\2/g, + (_, prefix: string) => `${prefix}'${relativePath}'`, + ); +// Every Android module in a local build links the flavor the resolver picks. export const ensureLocalOpenIapFlavorStrategy = ( contents: string, - flavor: OpenIapAndroidFlavor, + storeScriptPath: string, language: GradleLanguage = 'groovy', ): string => { - const existingBlockPattern = new RegExp( - `\\n?${escapeRegExp( - LOCAL_OPENIAP_FLAVOR_BLOCK_START, - )}[\\s\\S]*?${escapeRegExp(LOCAL_OPENIAP_FLAVOR_BLOCK_END)}\\n?`, - 'gm', - ); - const cleaned = contents - .replace(existingBlockPattern, '\n') - .replace(/\n{3,}/g, '\n\n') - .trimEnd(); + const cleaned = removeLocalOpenIapFlavorStrategy(contents).trimEnd(); const strategyBlock = - language === 'kotlin' - ? `project(":openiap-google") { + language === 'kt' + ? `apply(from = "${storeScriptPath}") +val openIapStore = + ((extra["openIapResolveStore"] as groovy.lang.Closure<*>).call("expo-iap") as Map<*, *>)["store"] as String + +project(":openiap-google") { layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("openiap-google")) } subprojects { - plugins.withId("com.android.library") { - extensions.configure("android") { - defaultConfig { - missingDimensionStrategy("platform", "${flavor}") + listOf("com.android.library", "com.android.application").forEach { pluginId -> + plugins.withId(pluginId) { + extensions.configure("android") { + defaultConfig { +${LOCAL_STRATEGY_LINE_KOTLIN} + } } } } }` - : `project(":openiap-google") { + : `apply from: "${storeScriptPath}" +def openIapStore = openIapResolveStore("expo-iap").store + +project(":openiap-google") { layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("openiap-google")) } subprojects { subproject -> - subproject.plugins.withId("com.android.library") { - subproject.android { - defaultConfig { - missingDimensionStrategy "platform", "${flavor}" + ["com.android.library", "com.android.application"].each { pluginId -> + subproject.plugins.withId(pluginId) { + subproject.android { + defaultConfig { +${LOCAL_STRATEGY_LINE_GROOVY} + } } } } @@ -166,11 +255,6 @@ const withLocalOpenIAP: ConfigPlugin< { localPath?: LocalPathOption; iosAlternativeBilling?: IOSAlternativeBillingConfig; - horizonAppId?: string; - /** Resolved from modules.horizon by withIAP */ - isHorizonEnabled?: boolean; - /** Resolved from modules.amazon.fireOS by withIAP */ - isFireOsEnabled?: boolean; /** Resolved from modules.onside by withIAP */ enableOnside?: boolean; } | void @@ -179,7 +263,6 @@ const withLocalOpenIAP: ConfigPlugin< if (props?.iosAlternativeBilling) { config = withIosAlternativeBilling(config, props.iosAlternativeBilling); } - // Helper to resolve Android module path const resolveAndroidModulePath = (p?: string): string | null => { if (!p) return null; // Prefer the module directory if it exists @@ -198,12 +281,17 @@ const withLocalOpenIAP: ConfigPlugin< } return null; }; + const androidInput = getAndroidLocalPathInput(props?.localPath); + // The local openiap-google module: from localPath, else beside the app. + const localAndroidModule = (projectRoot: string): string | null => + resolveAndroidModulePath(androidInput) ?? + resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')); // iOS: inject local pod path with wrapper podspec config = withDangerousMod(config, [ 'ios', async (config) => { - const {platformProjectRoot, projectRoot} = config.modRequest as any; + const {platformProjectRoot, projectRoot} = config.modRequest; const raw = props?.localPath; const iosPath = (typeof raw === 'string' ? raw : raw?.ios) || @@ -240,8 +328,21 @@ const withLocalOpenIAP: ConfigPlugin< } } + const relativePath = path + .relative(platformProjectRoot, iosPath) + .replace(/\\/g, '/'); + // Check if local OpenIAP pod is already configured if (podfileContent.includes("pod 'openiap',")) { + const updatedContent = setLocalOpenIapPodPath( + podfileContent, + relativePath, + ); + if (updatedContent !== podfileContent) { + podfileContent = updatedContent; + podfileChanged = true; + logOnce(`✅ Moved the local OpenIAP pod to: ${iosPath}`); + } if (podfileChanged) { fs.writeFileSync(podfilePath, podfileContent); } @@ -251,9 +352,6 @@ const withLocalOpenIAP: ConfigPlugin< const targetRegex = /target\s+['"][\w]+['"]\s+do\s*\n\s*use_expo_modules!/; - const relativePath = path - .relative(platformProjectRoot, iosPath) - .replace(/\\/g, '/'); if (targetRegex.test(podfileContent)) { podfileContent = podfileContent.replace(targetRegex, (match) => { @@ -281,40 +379,33 @@ const withLocalOpenIAP: ConfigPlugin< // Android: include local module and add dependency if available config = withSettingsGradle(config, (config) => { - const raw = props?.localPath; - const projectRoot = (config.modRequest as any).projectRoot as string; - const androidInput = getAndroidLocalPathInput(raw); - const androidModulePath = - resolveAndroidModulePath(androidInput) || - resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) || - null; - - if (!androidModulePath || !fs.existsSync(androidModulePath)) { + const androidModulePath = localAndroidModule(config.modRequest.projectRoot); + if (!androidModulePath) { if (androidInput) { console.warn( `⚠️ Could not resolve Android OpenIAP module at: ${androidInput}. Skipping local Android linkage.`, ); } + config.modResults.contents = removeLocalOpenIapSettings( + config.modResults.contents, + ); return config; } const pluginVersions = resolveAndroidGradlePluginVersions(androidModulePath); - const settingsRoot = - ((config.modRequest as any).platformProjectRoot as string | undefined) ?? - path.join(projectRoot, 'android'); const relativeAndroidModulePath = path - .relative(settingsRoot, androidModulePath) + .relative(config.modRequest.platformProjectRoot, androidModulePath) .replace(/\\/g, '/'); // 1) settings.gradle: include and map projectDir const settings = config.modResults; - const settingsLanguage = normalizeGradleLanguage(settings.language); + const settingsLanguage = settings.language; const includeLine = - settingsLanguage === 'kotlin' + settingsLanguage === 'kt' ? 'include(":openiap-google")' : "include ':openiap-google'"; const projectDirLine = - settingsLanguage === 'kotlin' + settingsLanguage === 'kt' ? `project(":openiap-google").projectDir = File(settingsDir, "${relativeAndroidModulePath}")` : `project(':openiap-google').projectDir = new File(settingsDir, '${relativeAndroidModulePath}')`; const includePattern = /include\s*(?:\(\s*)?["']:openiap-google["']\s*\)?/; @@ -405,66 +496,54 @@ const withLocalOpenIAP: ConfigPlugin< // 2) app/build.gradle: add implementation project(':openiap-google') config = withAppBuildGradle(config, (config) => { - const projectRoot = (config.modRequest as any).projectRoot as string; - const raw = props?.localPath; - const androidInput = getAndroidLocalPathInput(raw); - const androidModulePath = - resolveAndroidModulePath(androidInput) || - resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) || - null; - - if (!androidModulePath || !fs.existsSync(androidModulePath)) { + if (!localAndroidModule(config.modRequest.projectRoot)) { + config.modResults.contents = removeLocalOpenIapAppWiring( + config.modResults.contents, + ); return config; } const gradle = config.modResults; - const appLanguage = normalizeGradleLanguage(gradle.language); + const appLanguage = gradle.language; const dependencyLine = - appLanguage === 'kotlin' + appLanguage === 'kt' ? ` implementation(project(":openiap-google"))` : ` implementation project(':openiap-google')`; - const flavor = props?.isFireOsEnabled - ? 'amazon' - : props?.isHorizonEnabled - ? 'horizon' - : 'play'; - const strategyLine = - appLanguage === 'kotlin' - ? ` missingDimensionStrategy("platform", "${flavor}")` - : ` missingDimensionStrategy "platform", "${flavor}"`; - let contents = gradle.contents; - // Remove Maven deps for all openiap-google flavors - // to avoid duplicate classes with local module - const mavenPattern = - /^\s*(?:implementation|api)\s*\(?\s*["']io\.github\.hyochan\.openiap:openiap-google(?:-(?:horizon|amazon))?:[^"']+["']\s*\)?\s*$/gm; - if (mavenPattern.test(contents)) { - contents = contents.replace(mavenPattern, '\n'); - logOnce( - '🧹 Removed Maven openiap-google* dependencies (using local module)', - ); - } - - // Add missingDimensionStrategy (required for flavored module) - // Remove any existing platform strategies first to avoid duplicates + // `:app` runs before the root build file, so it applies the resolver itself. + const {apply: applyLine, strategy: strategyLine} = appStoreLines( + storeScriptPathFrom( + path.join(config.modRequest.platformProjectRoot, 'app'), + ), + appLanguage, + ); const strategyPattern = - /^\s*missingDimensionStrategy\s*\(?\s*["']platform["']\s*,\s*["'](play|horizon|amazon)["']\s*\)?\s*$/gm; - if (strategyPattern.test(contents)) { - contents = contents.replace(strategyPattern, ''); - logOnce('🧹 Removed existing missingDimensionStrategy for platform'); - } + /^[ \t]*missingDimensionStrategy[\s(]{0,4}["']platform["'][^\n]*\n?/gm; + contents = removeLocalOpenIapAppWiring(contents).replace( + strategyPattern, + '', + ); - if (!contents.includes(strategyLine)) { - const lines = contents.split('\n'); - const idx = lines.findIndex((line) => line.match(/defaultConfig\s*\{/)); - if (idx !== -1) { - lines.splice(idx + 1, 0, strategyLine); - contents = lines.join('\n'); - logOnce( - `🛠️ expo-iap: Added missingDimensionStrategy for ${flavor} flavor`, - ); - } + const androidBlock = /^(\s*)android\s*\{/m; + if (androidBlock.test(contents)) { + contents = contents.replace(androidBlock, (m) => `${applyLine}\n\n${m}`); + } else { + contents = `${applyLine}\n\n${contents}`; + } + const lines = contents.split('\n'); + const defaultConfigIndex = lines.findIndex((line) => + /defaultConfig\s*\{/.test(line), + ); + if (defaultConfigIndex !== -1) { + lines.splice(defaultConfigIndex + 1, 0, strategyLine); + contents = lines.join('\n'); + logOnce('🛠️ expo-iap: Wired app/build.gradle to the store resolver'); + } else { + WarningAggregator.addWarningAndroid( + 'expo-iap', + 'app/build.gradle has no defaultConfig block, so the local OpenIAP flavor is unselected.', + ); } // Add project dependency @@ -485,73 +564,44 @@ const withLocalOpenIAP: ConfigPlugin< // 2b) project build.gradle: Expo autolinked library modules can consume the // local flavored OpenIAP module transitively, so give them the same default. config = withProjectBuildGradle(config, (config) => { - const projectRoot = (config.modRequest as any).projectRoot as string; - const raw = props?.localPath; - const androidInput = getAndroidLocalPathInput(raw); - const androidModulePath = - resolveAndroidModulePath(androidInput) || - resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) || - null; - - if (!androidModulePath || !fs.existsSync(androidModulePath)) { + if (!localAndroidModule(config.modRequest.projectRoot)) { + config.modResults.contents = removeLocalOpenIapFlavorStrategy( + config.modResults.contents, + ); return config; } - const flavor = props?.isFireOsEnabled - ? 'amazon' - : props?.isHorizonEnabled - ? 'horizon' - : 'play'; config.modResults.contents = ensureLocalOpenIapFlavorStrategy( config.modResults.contents, - flavor, - normalizeGradleLanguage(config.modResults.language), + storeScriptPathFrom(config.modRequest.platformProjectRoot), + config.modResults.language, ); - logOnce(`🛠️ expo-iap: Added local OpenIAP flavor strategy for ${flavor}`); + logOnce('🛠️ expo-iap: Added the local OpenIAP build-time flavor strategy'); return config; }); - // 3) Set store flags in gradle.properties - config = withDangerousMod(config, [ - 'android', - async (config) => { - const {platformProjectRoot} = config.modRequest as any; - const gradlePropertiesPath = path.join( - platformProjectRoot, - 'gradle.properties', - ); - - let contents: string; - try { - contents = fs.readFileSync(gradlePropertiesPath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { - throw error; - } - return config; - } - const isHorizon = props?.isHorizonEnabled ?? false; - const isFireOS = props?.isFireOsEnabled ?? false; - - contents = contents.replace(/^horizonEnabled=.*$/gm, ''); - contents = contents.replace(/^fireOsEnabled=.*$/gm, ''); - if (!contents.endsWith('\n')) contents += '\n'; - contents += `horizonEnabled=${isHorizon}\n`; - contents += `fireOsEnabled=${isFireOS}\n`; - - fs.writeFileSync(gradlePropertiesPath, contents); - logOnce( - `🛠️ expo-iap: Set horizonEnabled=${isHorizon} in gradle.properties`, - ); - logOnce( - `🛠️ expo-iap: Set fireOsEnabled=${isFireOS} in gradle.properties`, - ); - - return config; - }, - ]); - return config; }; +export const withoutLocalOpenIAPAndroid: ConfigPlugin = (config) => { + config = withSettingsGradle(config, (config) => { + config.modResults.contents = removeLocalOpenIapSettings( + config.modResults.contents, + ); + return config; + }); + config = withAppBuildGradle(config, (config) => { + config.modResults.contents = removeLocalOpenIapAppWiring( + config.modResults.contents, + ); + return config; + }); + return withProjectBuildGradle(config, (config) => { + config.modResults.contents = removeLocalOpenIapFlavorStrategy( + config.modResults.contents, + ); + return config; + }); +}; + export default withLocalOpenIAP; diff --git a/libraries/expo-iap/src/ExpoIapModule.ts b/libraries/expo-iap/src/ExpoIapModule.ts index 41c70465f..0fc0c556e 100644 --- a/libraries/expo-iap/src/ExpoIapModule.ts +++ b/libraries/expo-iap/src/ExpoIapModule.ts @@ -1,14 +1,128 @@ -import {requireNativeModule, UnavailabilityError} from 'expo-modules-core'; +import { + requireNativeModule, + UnavailabilityError, + type EventSubscription, +} from 'expo-modules-core'; import {installedFromOnside} from './onside'; +import type { + BillingProgramAndroid, + BillingProgramReportingDetailsAndroid, + DeveloperBillingTypeAndroid, + Mutation, + MutationField, + ProductQueryType, + PurchaseInput, + PurchaseOptions, + PurchaseUpdatedListenerOptions, + Query, + QueryField, + SubscriptionStatusIOS, +} from './types'; import {getVegaIapModule, isVegaOS} from './vega'; type NativeIapModuleName = 'ExpoIapVega' | 'ExpoIapOnside' | 'ExpoIap'; const ONSIDE_MARKETPLACE_ID = 'com.onside.marketplace-app'; -let cached: {module: any; name: NativeIapModuleName} | null = null; +/** Members read from the raw module; every store module provides them. */ +type NativeEventModule = { + ERROR_CODES?: Record; + addListener( + eventName: string, + listener: (payload: T) => void, + ): EventSubscription | undefined; + removeListener?(eventName: string, listener: (payload: T) => void): void; + setPurchaseUpdatedListenerOptions?( + options?: PurchaseUpdatedListenerOptions | null, + ): Promise; +}; + +type QueryFields = {[P in K]: QueryField

}; +type MutationFields = {[P in K]: MutationField

}; + +/** + * Native surface behind the default export. Results the wrappers decode stay + * `unknown`; the rest match the generated operation signatures. + */ +export type ExpoIapNativeModule = NativeEventModule & + QueryFields< + | 'canPresentExternalPurchaseNoticeIOS' + | 'currentEntitlementIOS' + | 'getActiveSubscriptions' + | 'getAppTransactionIOS' + | 'getBillingChoiceInfoAndroid' + | 'getExternalPurchaseCustomLinkTokenIOS' + | 'getPromotedProductIOS' + | 'getReceiptDataIOS' + | 'getTransactionJwsIOS' + | 'hasActiveSubscriptions' + | 'isEligibleForExternalPurchaseCustomLinkIOS' + | 'isEligibleForIntroOfferIOS' + | 'isTransactionVerifiedIOS' + | 'latestTransactionIOS' + > & + MutationFields< + | 'beginRefundRequestIOS' + | 'clearTransactionIOS' + | 'endConnection' + | 'initConnection' + | 'isBillingProgramAvailableAndroid' + | 'launchExternalLinkAndroid' + | 'openRedeemOfferCodeAndroid' + | 'presentCodeRedemptionSheetIOS' + | 'presentExternalPurchaseLinkIOS' + | 'presentExternalPurchaseNoticeSheetIOS' + | 'showBillingProgramInformationDialogAndroid' + | 'showExternalPurchaseCustomLinkNoticeIOS' + | 'showInAppMessagesAndroid' + | 'syncIOS' + | 'verifyPurchase' + | 'verifyPurchaseWithProvider' + > & { + USING_ONSIDE_SDK: boolean; + USING_VEGA_SDK: boolean; + fetchProducts(request: { + skus: string[]; + type: ProductQueryType; + }): Promise; + fetchProducts(type: ProductQueryType, skus: string[]): Promise; + getAvailableItems( + alsoPublishToEventListenerIOS: boolean, + onlyIncludeActiveItemsIOS: boolean, + ): Promise; + getAvailableItems(options: PurchaseOptions): Promise; + getAllTransactionsIOS(): Promise; + getPendingTransactionsIOS(): Promise; + showManageSubscriptionsIOS(): Promise; + requestPurchase(request: object): Promise; + finishTransaction( + purchase: PurchaseInput, + isConsumable: boolean | null, + ): Promise; + acknowledgePurchaseAndroid(purchaseToken: string): Promise; + consumePurchaseAndroid(purchaseToken: string): Promise; + createBillingProgramReportingDetailsAndroid( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid | null, + ): Promise; + subscriptionStatusIOS(sku: string): Promise; + requestReceiptRefreshIOS(): Promise; + deepLinkToSubscriptionsAndroid?(options: { + skuAndroid?: string; + packageNameAndroid?: string; + }): Promise | void; + getStorefront?(): Promise | string; + restorePurchases?(): Promise; + }; + +type ResolvedNativeModule = { + module: NativeEventModule; + name: NativeIapModuleName; +}; + +let cached: ResolvedNativeModule | null = null; let onsideModuleUnavailable = false; -function getResolved(): {module: any; name: NativeIapModuleName} { +function getResolved(): ResolvedNativeModule { function shouldUseOnsideModule(): boolean { if (installedFromOnside === true) { return true; @@ -30,10 +144,7 @@ function getResolved(): {module: any; name: NativeIapModuleName} { return shouldUseOnsideModule() ? 'ExpoIapOnside' : 'ExpoIap'; } - function resolveNativeModule(): { - module: any; - name: NativeIapModuleName; - } { + function resolveNativeModule(): ResolvedNativeModule { if (isVegaOS()) { const vegaModule = getVegaIapModule(); if (!vegaModule) { @@ -54,7 +165,7 @@ function getResolved(): {module: any; name: NativeIapModuleName} { } try { return { - module: requireNativeModule('ExpoIapOnside'), + module: requireNativeModule('ExpoIapOnside'), name: 'ExpoIapOnside', }; } catch (error) { @@ -69,7 +180,10 @@ function getResolved(): {module: any; name: NativeIapModuleName} { } } - return {module: requireNativeModule('ExpoIap'), name: 'ExpoIap'}; + return { + module: requireNativeModule('ExpoIap'), + name: 'ExpoIap', + }; } const expectedName = getExpectedModuleName(); @@ -96,22 +210,24 @@ export const NATIVE_ERROR_CODES: Record = new Proxy( { get(target, prop) { if (typeof prop === 'symbol') return Reflect.get(target, prop); - return (getResolved().module.ERROR_CODES || {})[prop as string]; + const errorCodes: Record = + getResolved().module.ERROR_CODES || {}; + return errorCodes[prop]; }, }, ); /** - * Returns the raw native module (not wrapped in a Proxy). - * Use this for EventEmitter / addListener calls — JSI HostObjects - * require the real native module as `this`; a Proxy triggers - * "native state unsupported on Proxy" on New Architecture / Hermes. + * Returns the raw native module, not the Proxy. Use it for addListener: a JSI + * HostObject needs the real module as `this`, and a Proxy throws "native state + * unsupported on Proxy" on New Architecture / Hermes. */ -export function getNativeModule() { +export function getNativeModule(): NativeEventModule { return getResolved().module; } -export default new Proxy({} as any, { +// The Proxy forwards every member to the lazily resolved store module. +export default new Proxy({} as ExpoIapNativeModule, { get(target, prop) { if (typeof prop === 'symbol') return Reflect.get(target, prop); const resolved = getResolved(); @@ -122,7 +238,7 @@ export default new Proxy({} as any, { return resolved.name === 'ExpoIapVega'; } - const value = resolved.module[prop]; + const value: unknown = Reflect.get(resolved.module, prop); if (value !== undefined || resolved.name !== 'ExpoIapOnside') { return value; } @@ -130,7 +246,9 @@ export default new Proxy({} as any, { return () => { throw new UnavailabilityError( 'expo-iap', - `The Onside marketplace does not support ${String(prop)}. The call was not routed through Apple StoreKit.`, + `The Onside marketplace does not support ${String( + prop, + )}. The call was not routed through Apple StoreKit.`, ); }; }, diff --git a/libraries/expo-iap/src/__tests__/conformance.test.ts b/libraries/expo-iap/src/__tests__/conformance.test.ts index 3e888bfe8..2b035ec42 100644 --- a/libraries/expo-iap/src/__tests__/conformance.test.ts +++ b/libraries/expo-iap/src/__tests__/conformance.test.ts @@ -1,12 +1,7 @@ /** - * expo-iap's binding into the OpenIAP conformance suite. - * - * The native module is replaced with a deterministic fake store so the real SDK - * wrappers in src/index.ts run against controlled store responses. Purchase, - * completion, and restoration behaviors are only testable this way — a real - * purchase cannot happen in CI. - * - * Behavior ids match packages/conformance/src/spec/behaviors.mjs. + * expo-iap's binding into the OpenIAP conformance suite. A deterministic fake + * native store drives the real wrappers in src/index.ts, because CI cannot make + * a real purchase. Behavior ids match packages/conformance/src/spec/behaviors.mjs. */ export {}; @@ -80,7 +75,7 @@ const nativeModule: Record = { // fetchProducts(type, skus) on the legacy signature; support both. const skus: string[] = Array.isArray(legacySkus) ? legacySkus - : (params?.skus ?? []); + : params?.skus ?? []; const rawType = Array.isArray(legacySkus) ? params : params?.type; const type = rawType === 'all' ? undefined : rawType; @@ -211,7 +206,7 @@ jest.mock('react-native', () => ({ /* eslint-disable import/first */ import * as IAP from '../index'; -import {ErrorCode} from '../types'; +import {ErrorCode, type Purchase} from '../types'; /** Behavior ids from packages/conformance this suite verifies. */ const COVERED_BEHAVIORS = [ @@ -236,11 +231,26 @@ const COVERED_BEHAVIORS = [ 'verification.infrastructure-error-is-not-a-verdict', ]; -const buy = (sku: string) => - IAP.requestPurchase({ +type TokenizedPurchase = Purchase & {purchaseToken: string}; + +const isTokenizedPurchase = ( + purchase: Purchase | Purchase[] | null, +): purchase is TokenizedPurchase => + purchase != null && + !Array.isArray(purchase) && + typeof purchase.purchaseToken === 'string'; + +// The fake store resolves one tokenized purchase per request. +const buy = async (sku: string): Promise => { + const purchase = await IAP.requestPurchase({ request: {google: {skus: [sku]}}, type: 'in-app', - } as never); + }); + if (!isTokenizedPurchase(purchase)) { + throw new Error(`Expected one tokenized purchase for ${sku}`); + } + return purchase; +}; describe('conformance: expo-iap', () => { beforeEach(() => { @@ -259,29 +269,29 @@ describe('conformance: expo-iap', () => { skus: ['dev.hyo.martie.10bulbs', 'not-a-real-sku'], type: 'in-app', }); - expect((products as any[]).map((product) => product.id)).toEqual([ + expect(products?.map((product) => product.id)).toEqual([ 'dev.hyo.martie.10bulbs', ]); }); it('products.fetch-normalizes-required-fields', async () => { - const products = (await IAP.fetchProducts({ + const products = await IAP.fetchProducts({ skus: ['dev.hyo.martie.10bulbs'], type: 'in-app', - })) as any[]; - const [product] = products; - expect(product.id).toBeTruthy(); - expect(product.title).toBeTruthy(); - expect(product.currency).toBeTruthy(); - expect(product.displayPrice).toBeTruthy(); + }); + const product = products?.[0]; + expect(product?.id).toBeTruthy(); + expect(product?.title).toBeTruthy(); + expect(product?.currency).toBeTruthy(); + expect(product?.displayPrice).toBeTruthy(); }); it('products.fetch-separates-in-app-and-subscription-types', async () => { - const subs = (await IAP.fetchProducts({ + const subs = await IAP.fetchProducts({ skus: ['dev.hyo.martie.premium', 'dev.hyo.martie.10bulbs'], type: 'subs', - })) as any[]; - expect(subs.map((product) => product.id)).toEqual([ + }); + expect(subs?.map((product) => product.id)).toEqual([ 'dev.hyo.martie.premium', ]); }); @@ -297,7 +307,7 @@ describe('conformance: expo-iap', () => { // --- purchases ---------------------------------------------------------- it('purchases.request-emits-purchase-updated-on-success', async () => { - const purchase = (await buy('dev.hyo.martie.10bulbs')) as any; + const purchase = await buy('dev.hyo.martie.10bulbs'); expect(purchase.productId).toBe('dev.hyo.martie.10bulbs'); expect(purchase.purchaseState).toBe('purchased'); }); @@ -311,7 +321,7 @@ describe('conformance: expo-iap', () => { it('purchases.pending-purchase-is-not-delivered-as-purchased', async () => { fakeStore.forced.set('dev.hyo.martie.10bulbs', 'pending'); - const purchase = (await buy('dev.hyo.martie.10bulbs')) as any; + const purchase = await buy('dev.hyo.martie.10bulbs'); expect(purchase.purchaseState).not.toBe('purchased'); expect(purchase.purchaseState).toBe('pending'); }); @@ -328,7 +338,7 @@ describe('conformance: expo-iap', () => { await buy('dev.hyo.martie.lifetime'); await buy('dev.hyo.martie.premium'); - const available = (await IAP.getAvailablePurchases()) as any[]; + const available = await IAP.getAvailablePurchases(); expect(available.map((item) => item.productId).sort()).toEqual([ 'dev.hyo.martie.lifetime', 'dev.hyo.martie.premium', @@ -336,10 +346,10 @@ describe('conformance: expo-iap', () => { }); it('restoration.available-purchases-excludes-consumed-items', async () => { - const purchase = (await buy('dev.hyo.martie.10bulbs')) as any; + const purchase = await buy('dev.hyo.martie.10bulbs'); await IAP.finishTransaction({purchase, isConsumable: true}); - const available = (await IAP.getAvailablePurchases()) as any[]; + const available = await IAP.getAvailablePurchases(); expect( available.some((item) => item.purchaseToken === purchase.purchaseToken), ).toBe(false); @@ -353,15 +363,15 @@ describe('conformance: expo-iap', () => { it('subscriptions.active-subscription-is-reported-active', async () => { await buy('dev.hyo.martie.premium'); - const [subscription] = (await IAP.getActiveSubscriptions()) as any[]; - expect(subscription.isActive).toBe(true); + const [subscription] = await IAP.getActiveSubscriptions(); + expect(subscription?.isActive).toBe(true); }); it('subscriptions.groups-keep-independent-identifiers', async () => { await buy('dev.hyo.martie.premium'); await buy('dev.hyo.martie.pro'); - const subscriptions = (await IAP.getActiveSubscriptions()) as any[]; + const subscriptions = await IAP.getActiveSubscriptions(); const premium = subscriptions.find( (item) => item.productId === 'dev.hyo.martie.premium', ); @@ -369,9 +379,9 @@ describe('conformance: expo-iap', () => { (item) => item.productId === 'dev.hyo.martie.pro', ); - expect(premium.currentPlanId).toBe('dev.hyo.martie.premium'); - expect(pro.currentPlanId).toBe('dev.hyo.martie.pro'); - expect(premium.purchaseToken).not.toBe(pro.purchaseToken); + expect(premium?.currentPlanId).toBe('dev.hyo.martie.premium'); + expect(pro?.currentPlanId).toBe('dev.hyo.martie.pro'); + expect(premium?.purchaseToken).not.toBe(pro?.purchaseToken); }); it('subscriptions.has-active-agrees-with-get-active', async () => { @@ -383,17 +393,15 @@ describe('conformance: expo-iap', () => { // --- identifiers -------------------------------------------------------- it('identifiers.purchase-carries-a-concrete-store', async () => { - const purchase = (await buy('dev.hyo.martie.10bulbs')) as any; + const purchase = await buy('dev.hyo.martie.10bulbs'); expect(purchase.store).toBeTruthy(); expect(purchase.store).not.toBe('unknown'); }); it('identifiers.purchase-token-is-stable-across-reads', async () => { - const purchase = (await buy('dev.hyo.martie.lifetime')) as any; - const first = ((await IAP.getAvailablePurchases()) as any[])[0] - .purchaseToken; - const second = ((await IAP.getAvailablePurchases()) as any[])[0] - .purchaseToken; + const purchase = await buy('dev.hyo.martie.lifetime'); + const first = (await IAP.getAvailablePurchases())[0]?.purchaseToken; + const second = (await IAP.getAvailablePurchases())[0]?.purchaseToken; expect(first).toBe(purchase.purchaseToken); expect(second).toBe(purchase.purchaseToken); @@ -402,9 +410,9 @@ describe('conformance: expo-iap', () => { // --- verification ------------------------------------------------------- it('verification.result-exposes-uniform-validity', async () => { - const purchase = (await buy('dev.hyo.martie.lifetime')) as any; + const purchase = await buy('dev.hyo.martie.lifetime'); - const valid = (await IAP.verifyPurchase({ + const valid = await IAP.verifyPurchase({ google: { sku: 'dev.hyo.martie.lifetime', packageName: 'dev.hyo.martie', @@ -412,7 +420,7 @@ describe('conformance: expo-iap', () => { accessToken: 'test-access-token', isSub: false, }, - } as never)) as any; + }); expect(typeof valid.isValid).toBe('boolean'); expect(valid.isValid).toBe(true); @@ -421,7 +429,7 @@ describe('conformance: expo-iap', () => { it('verification.forged-token-is-invalid', async () => { await buy('dev.hyo.martie.lifetime'); - const result = (await IAP.verifyPurchase({ + const result = await IAP.verifyPurchase({ google: { sku: 'dev.hyo.martie.lifetime', packageName: 'dev.hyo.martie', @@ -429,13 +437,13 @@ describe('conformance: expo-iap', () => { accessToken: 'test-access-token', isSub: false, }, - } as never)) as any; + }); expect(result.isValid).toBe(false); }); it('verification.infrastructure-error-is-not-a-verdict', async () => { - const purchase = (await buy('dev.hyo.martie.lifetime')) as any; + const purchase = await buy('dev.hyo.martie.lifetime'); fakeStore.verifierAvailable = false; await expect( @@ -447,7 +455,7 @@ describe('conformance: expo-iap', () => { accessToken: 'test-access-token', isSub: false, }, - } as never), + }), // The statement requires a ServiceError/NetworkError surface, not just // any rejection. ).rejects.toMatchObject({ diff --git a/libraries/expo-iap/src/__tests__/index.kepler.test.ts b/libraries/expo-iap/src/__tests__/index.kepler.test.ts index a28309d31..3f9e4418e 100644 --- a/libraries/expo-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/expo-iap/src/__tests__/index.kepler.test.ts @@ -6,13 +6,26 @@ import { requestPurchase, } from '../index.kepler'; import * as Kepler from '../index.kepler'; -import {ErrorCode} from '../types'; +import {ErrorCode, type PurchaseAndroid} from '../types'; import {getVegaIapModule} from '../vega'; jest.mock('../vega', () => ({ getVegaIapModule: jest.fn(), })); +const vegaPurchase = ( + overrides: Partial = {}, +): PurchaseAndroid => ({ + id: 'transaction', + productId: 'premium', + isAutoRenewing: false, + purchaseState: 'purchased', + quantity: 1, + store: 'amazon', + transactionDate: 1720000000000, + ...overrides, +}); + describe('Amazon Vega public API', () => { const fetchProductsNative = jest.fn().mockResolvedValue([]); const getAvailablePurchasesNative = jest.fn().mockResolvedValue([]); @@ -37,7 +50,8 @@ describe('Amazon Vega public API', () => { it("rejects the removed 'inapp' product type", async () => { await expect( - fetchProducts({skus: ['coins'], type: 'inapp'} as any), + // @ts-expect-error the removed alias reaches the runtime check + fetchProducts({skus: ['coins'], type: 'inapp'}), ).rejects.toThrow(/Unsupported product type/); expect(fetchProductsNative).not.toHaveBeenCalled(); }); @@ -56,8 +70,9 @@ describe('Amazon Vega public API', () => { const request = { request: {android: {skus: ['coins']}}, type: 'in-app', - } as any; + }; + // @ts-expect-error the removed alias reaches the runtime check await expect(requestPurchase(request)).rejects.toThrow( /request\.google\.skus/, ); @@ -69,10 +84,11 @@ describe('Amazon Vega public API', () => { requestPurchase({ request: { google: null, + // @ts-expect-error the removed alias reaches the runtime check android: {skus: ['legacy-coins']}, }, type: 'in-app', - } as any), + }), ).rejects.toThrow(/request\.google\.skus/); expect(requestPurchaseNative).not.toHaveBeenCalled(); @@ -82,7 +98,8 @@ describe('Amazon Vega public API', () => { await expect( requestPurchase({ request: {google: {skus: ['coins']}}, - type: 'all' as any, + // @ts-expect-error query-only type reaches the runtime check + type: 'all', }), ).rejects.toMatchObject({ code: ErrorCode.DeveloperError, @@ -167,17 +184,13 @@ describe('Amazon Vega public API', () => { verifyPurchaseWithProvider: jest.fn().mockResolvedValue({isValid: true}), }; (getVegaIapModule as jest.Mock).mockReturnValue(module); - const purchase = { - id: 'transaction', - productId: 'premium', - purchaseToken: 'opaque-token', - } as any; + const purchase = vegaPurchase({purchaseToken: 'opaque-token'}); await expect( Kepler.requestPurchase({ request: {google: {skus: ['premium']}}, type: 'subs', - } as any), + }), ).resolves.toEqual([]); await Kepler.finishTransaction({purchase, isConsumable: true}); await Kepler.finishTransaction({purchase, isConsumable: false}); @@ -208,7 +221,7 @@ describe('Amazon Vega public API', () => { it('rejects transaction completion without a purchase token', async () => { await expect( Kepler.finishTransaction({ - purchase: {productId: 'premium'} as any, + purchase: vegaPurchase(), isConsumable: false, }), ).rejects.toMatchObject({ @@ -217,21 +230,49 @@ describe('Amazon Vega public API', () => { }); }); - it.each([ - 'verifyPurchase', - 'syncIOS', - 'presentExternalPurchaseLinkIOS', - 'deepLinkToSubscriptions', - 'isBillingProgramAvailableAndroid', - 'getBillingChoiceInfoAndroid', - 'launchExternalLinkAndroid', - 'createBillingProgramReportingDetailsAndroid', - 'showBillingProgramInformationDialogAndroid', - 'showInAppMessagesAndroid', - ])('rejects unsupported %s calls', async (api) => { - await expect((Kepler as any)[api]()).rejects.toThrow( - 'not supported on Amazon Vega', - ); + it.each<[string, () => Promise]>([ + ['verifyPurchase', () => Kepler.verifyPurchase({apple: {sku: 'premium'}})], + ['syncIOS', () => Kepler.syncIOS()], + [ + 'presentExternalPurchaseLinkIOS', + () => Kepler.presentExternalPurchaseLinkIOS('https://example.com'), + ], + ['deepLinkToSubscriptions', () => Kepler.deepLinkToSubscriptions()], + [ + 'isBillingProgramAvailableAndroid', + () => Kepler.isBillingProgramAvailableAndroid('external-offer'), + ], + [ + 'getBillingChoiceInfoAndroid', + () => Kepler.getBillingChoiceInfoAndroid({}), + ], + [ + 'launchExternalLinkAndroid', + () => + Kepler.launchExternalLinkAndroid({ + billingProgram: 'external-offer', + launchMode: 'launch-in-external-browser-or-app', + linkType: 'link-to-digital-content-offer', + linkUri: 'https://example.com', + }), + ], + [ + 'createBillingProgramReportingDetailsAndroid', + () => + Kepler.createBillingProgramReportingDetailsAndroid({ + program: 'external-offer', + }), + ], + [ + 'showBillingProgramInformationDialogAndroid', + () => + Kepler.showBillingProgramInformationDialogAndroid({ + externalTransactionToken: 'token', + }), + ], + ['showInAppMessagesAndroid', () => Kepler.showInAppMessagesAndroid()], + ])('rejects unsupported %s calls', async (_api, call) => { + await expect(call()).rejects.toThrow('not supported on Amazon Vega'); }); it('returns inert subscriptions for unavailable listener APIs', () => { diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index cfb74fbce..03913363e 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -29,7 +29,10 @@ import { subscriptionBillingIssueListener, userChoiceBillingListenerAndroid, developerProvidedBillingListenerAndroid, - PurchaseInput, + type ProductType, + type PurchaseInput, + type RequestPurchaseAndroidProps, + type RequestSubscriptionAndroidProps, getActiveSubscriptions, hasActiveSubscriptions, openRedeemOfferCode, @@ -58,6 +61,24 @@ const nativePurchase = ( ...overrides, }); +const registeredListener = (index: number): ((payload: unknown) => void) => { + const call = jest.mocked(ExpoIapModule.addListener).mock.calls[index]; + if (!call) { + throw new Error(`addListener call ${index} was not recorded`); + } + return call[1]; +}; + +const purchaseUpdatedOptionsMock = () => { + const setOptions = ExpoIapModule.setPurchaseUpdatedListenerOptions; + if (!setOptions) { + throw new Error( + 'The native mock defines setPurchaseUpdatedListenerOptions', + ); + } + return jest.mocked(setOptions); +}; + afterEach(() => { consoleLogSpy.mockClear(); }); @@ -69,31 +90,29 @@ afterAll(() => { describe('Public API (index.ts)', () => { beforeEach(() => { jest.clearAllMocks(); - (Platform as any).OS = 'ios'; - (Platform as any).select = jest.fn((obj) => obj.ios); + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.getPromotedProductIOS as jest.Mock).mockResolvedValue(null); }); describe('listeners', () => { it('registers purchase updated listener', () => { - const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const addListener = jest.mocked(ExpoIapModule.addListener); const fn = jest.fn(); const subscription = purchaseUpdatedListener(fn); expect(addListener).toHaveBeenCalledWith( OpenIapEvent.PurchaseUpdated, expect.any(Function), ); - const passed = addListener.mock.calls[0][1]; - const event = {id: 't', productId: 'p', store: 'apple'} as any; + const passed = registeredListener(0); + const event = {id: 't', productId: 'p', store: 'apple'}; passed(event); expect(fn).toHaveBeenCalledWith(event); expect(typeof subscription.remove).toBe('function'); }); it('registers non-deduping purchase updated listener on iOS', () => { - const addListener = (ExpoIapModule as any).addListener as jest.Mock; - const setOptions = (ExpoIapModule as any) - .setPurchaseUpdatedListenerOptions as jest.Mock; + const addListener = jest.mocked(ExpoIapModule.addListener); + const setOptions = purchaseUpdatedOptionsMock(); const fn = jest.fn(); const subscription = purchaseUpdatedListener(fn, { dedupeTransactionIOS: false, @@ -109,7 +128,7 @@ describe('Public API (index.ts)', () => { }); it('removes listener through native subscription when available', () => { - const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const addListener = jest.mocked(ExpoIapModule.addListener); const nativeRemove = jest.fn(); addListener.mockReturnValueOnce({remove: nativeRemove}); @@ -121,12 +140,12 @@ describe('Public API (index.ts)', () => { }); it('falls back to native removeListener when addListener returns void', () => { - const addListener = (ExpoIapModule as any).addListener as jest.Mock; - const removeListener = (ExpoIapModule as any).removeListener as jest.Mock; + const addListener = jest.mocked(ExpoIapModule.addListener); + const removeListener = ExpoIapModule.removeListener; addListener.mockReturnValueOnce(undefined); const subscription = purchaseUpdatedListener(jest.fn()); - const nativeListener = addListener.mock.calls[0][1]; + const nativeListener = registeredListener(0); subscription.remove(); subscription.remove(); @@ -138,7 +157,6 @@ describe('Public API (index.ts)', () => { }); it('filters duplicate replay events for default listeners when a non-deduping listener is active', () => { - const addListener = (ExpoIapModule as any).addListener as jest.Mock; const defaultListener = jest.fn(); const nonDedupingListener = jest.fn(); @@ -150,13 +168,13 @@ describe('Public API (index.ts)', () => { }, ); - const defaultHandler = addListener.mock.calls[0][1]; - const nonDedupingHandler = addListener.mock.calls[1][1]; + const defaultHandler = registeredListener(0); + const nonDedupingHandler = registeredListener(1); const event = { id: 'expo-dedupe-replay', productId: 'p', platform: 'IOS', - } as any; + }; defaultHandler(event); nonDedupingHandler(event); @@ -169,17 +187,16 @@ describe('Public API (index.ts)', () => { }); it('resets default listener duplicate history after endConnection', async () => { - const addListener = (ExpoIapModule as any).addListener as jest.Mock; (ExpoIapModule.endConnection as jest.Mock).mockResolvedValue(true); const listener = jest.fn(); purchaseUpdatedListener(listener); - const handler = addListener.mock.calls[0][1]; + const handler = registeredListener(0); const event = { id: 'expo-dedupe-after-reconnect', productId: 'p', platform: 'IOS', - } as any; + }; handler(event); handler(event); @@ -192,11 +209,10 @@ describe('Public API (index.ts)', () => { }); it('reapplies non-deduping purchase updated option after reconnect', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.initConnection as jest.Mock).mockResolvedValue(true); (ExpoIapModule.endConnection as jest.Mock).mockResolvedValue(true); - const setOptions = (ExpoIapModule as any) - .setPurchaseUpdatedListenerOptions as jest.Mock; + const setOptions = purchaseUpdatedOptionsMock(); const subscription = purchaseUpdatedListener(jest.fn(), { dedupeTransactionIOS: false, @@ -217,8 +233,7 @@ describe('Public API (index.ts)', () => { }); it('removes non-deduping purchase updated listeners idempotently', () => { - const setOptions = (ExpoIapModule as any) - .setPurchaseUpdatedListenerOptions as jest.Mock; + const setOptions = purchaseUpdatedOptionsMock(); const firstSubscription = purchaseUpdatedListener(jest.fn(), { dedupeTransactionIOS: false, @@ -240,14 +255,14 @@ describe('Public API (index.ts)', () => { }); it('registers purchase error listener', () => { - const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const addListener = jest.mocked(ExpoIapModule.addListener); const fn = jest.fn(); purchaseErrorListener(fn); expect(addListener).toHaveBeenCalledWith( OpenIapEvent.PurchaseError, expect.any(Function), ); - const passed = addListener.mock.calls[0][1]; + const passed = registeredListener(0); const err = { message: 'm', code: 'query-product', @@ -258,22 +273,21 @@ describe('Public API (index.ts)', () => { productType: 'subs', isEmptyProductList: false, subResponseCodeAndroid: 'user-ineligible', - } as any; + }; passed(err); expect(fn).toHaveBeenCalledWith(err); }); it('promotedProductListenerIOS warns on non‑iOS, adds on iOS', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const sub = promotedProductListenerIOS(jest.fn()); expect(typeof sub.remove).toBe('function'); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); - (Platform as any).OS = 'ios'; - (Platform as any).select = (obj: any) => obj.ios; - const addListener = (ExpoIapModule as any).addListener as jest.Mock; + Object.assign(Platform, {OS: 'ios'}); + const addListener = jest.mocked(ExpoIapModule.addListener); promotedProductListenerIOS(jest.fn()); expect(addListener).toHaveBeenCalledWith( 'promoted-product-ios', @@ -282,8 +296,8 @@ describe('Public API (index.ts)', () => { }); it('promotedProductListenerIOS replays pending promoted product on iOS', async () => { - (Platform as any).OS = 'ios'; - const product = {id: 'promoted-product', platform: 'ios'} as any; + Object.assign(Platform, {OS: 'ios'}); + const product = {id: 'promoted-product', platform: 'ios'}; (ExpoIapModule.getPromotedProductIOS as jest.Mock).mockResolvedValue( product, ); @@ -297,16 +311,15 @@ describe('Public API (index.ts)', () => { }); it('promotedProductListenerIOS dedupes replayed promoted product', async () => { - (Platform as any).OS = 'ios'; - const product = {id: 'promoted-product', platform: 'ios'} as any; + Object.assign(Platform, {OS: 'ios'}); + const product = {id: 'promoted-product', platform: 'ios'}; (ExpoIapModule.getPromotedProductIOS as jest.Mock).mockResolvedValue( product, ); - const addListener = (ExpoIapModule as any).addListener as jest.Mock; const listener = jest.fn(); promotedProductListenerIOS(listener); - const nativeListener = addListener.mock.calls[0][1]; + const nativeListener = registeredListener(0); nativeListener('promoted-product'); await Promise.resolve(); @@ -314,16 +327,15 @@ describe('Public API (index.ts)', () => { }); it('promotedProductListenerIOS resolves native SKU payloads', async () => { - (Platform as any).OS = 'ios'; - const product = {id: 'promoted-product', platform: 'ios'} as any; + Object.assign(Platform, {OS: 'ios'}); + const product = {id: 'promoted-product', platform: 'ios'}; (ExpoIapModule.getPromotedProductIOS as jest.Mock) .mockResolvedValueOnce(null) .mockResolvedValueOnce(product); - const addListener = (ExpoIapModule as any).addListener as jest.Mock; const listener = jest.fn(); promotedProductListenerIOS(listener); - const nativeListener = addListener.mock.calls[0][1]; + const nativeListener = registeredListener(0); nativeListener('promoted-product'); await Promise.resolve(); @@ -332,15 +344,15 @@ describe('Public API (index.ts)', () => { }); it('userChoiceBillingListenerAndroid warns on non‑Android, adds on Android', () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const sub = userChoiceBillingListenerAndroid(jest.fn()); expect(typeof sub.remove).toBe('function'); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); - (Platform as any).OS = 'android'; - const addListener = (ExpoIapModule as any).addListener as jest.Mock; + Object.assign(Platform, {OS: 'android'}); + const addListener = jest.mocked(ExpoIapModule.addListener); userChoiceBillingListenerAndroid(jest.fn()); expect(addListener).toHaveBeenCalledWith( OpenIapEvent.UserChoiceBillingAndroid, @@ -349,15 +361,15 @@ describe('Public API (index.ts)', () => { }); it('developerProvidedBillingListenerAndroid warns on non‑Android, adds on Android', () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const sub = developerProvidedBillingListenerAndroid(jest.fn()); expect(typeof sub.remove).toBe('function'); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); - (Platform as any).OS = 'android'; - const addListener = (ExpoIapModule as any).addListener as jest.Mock; + Object.assign(Platform, {OS: 'android'}); + const addListener = jest.mocked(ExpoIapModule.addListener); const fn = jest.fn(); developerProvidedBillingListenerAndroid(fn); expect(addListener).toHaveBeenCalledWith( @@ -367,21 +379,22 @@ describe('Public API (index.ts)', () => { }); it('developerProvidedBillingListenerAndroid receives correct event data', () => { - (Platform as any).OS = 'android'; - const addListener = (ExpoIapModule as any).addListener as jest.Mock; + Object.assign(Platform, {OS: 'android'}); + const addListener = jest.mocked(ExpoIapModule.addListener); const fn = jest.fn(); developerProvidedBillingListenerAndroid(fn); // Get the callback that was registered const registeredCallback = addListener.mock.calls.find( - (call: any) => call[0] === OpenIapEvent.DeveloperProvidedBillingAndroid, + ([eventName]) => + eventName === OpenIapEvent.DeveloperProvidedBillingAndroid, )?.[1]; // Simulate event with external transaction token const mockDetails = { externalTransactionToken: 'ext-txn-token-12345', }; - registeredCallback(mockDetails); + registeredCallback?.(mockDetails); expect(fn).toHaveBeenCalledWith(mockDetails); expect(fn.mock.calls[0][0].externalTransactionToken).toBe( @@ -390,7 +403,7 @@ describe('Public API (index.ts)', () => { }); it('subscriptionBillingIssueListener forwards the canonical purchase', () => { - const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const addListener = jest.mocked(ExpoIapModule.addListener); const fn = jest.fn(); subscriptionBillingIssueListener(fn); @@ -400,14 +413,14 @@ describe('Public API (index.ts)', () => { ); const registeredCallback = addListener.mock.calls.find( - (call: any) => call[0] === OpenIapEvent.SubscriptionBillingIssue, + ([eventName]) => eventName === OpenIapEvent.SubscriptionBillingIssue, )?.[1]; const purchase = { id: 'billing-issue', productId: 'sub.monthly', store: 'apple', - } as any; - registeredCallback(purchase); + }; + registeredCallback?.(purchase); expect(fn).toHaveBeenCalledWith(purchase); }); @@ -448,8 +461,7 @@ describe('Public API (index.ts)', () => { describe('fetchProducts', () => { it('filters iOS products by skus', async () => { - (Platform as any).OS = 'ios'; - (Platform as any).select = (obj: any) => obj.ios; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.fetchProducts as jest.Mock) = jest.fn().mockResolvedValue([ {platform: 'ios', id: 'a'}, {platform: 'ios', id: 'b'}, @@ -460,8 +472,7 @@ describe('Public API (index.ts)', () => { }); it('filters Android products by skus', async () => { - (Platform as any).OS = 'android'; - (Platform as any).select = (obj: any) => obj.android; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.fetchProducts as jest.Mock) = jest.fn().mockResolvedValue([ {platform: 'android', id: 'sub1'}, {platform: 'android', id: 'sub2'}, @@ -479,32 +490,31 @@ describe('Public API (index.ts)', () => { fetchProducts({skus: [], type: 'in-app'}), ).rejects.toMatchObject({ code: 'empty-sku-list', - } as any); + }); }); it('fetchProducts default path throws unsupported platform', async () => { - (Platform as any).OS = 'windows'; - await expect(fetchProducts({skus: ['a']} as any)).rejects.toThrow( + Object.assign(Platform, {OS: 'windows'}); + await expect(fetchProducts({skus: ['a']})).rejects.toThrow( /Unsupported platform/, ); }); it('rejects the removed inapp type alias', async () => { - (Platform as any).OS = 'ios'; - (Platform as any).select = (obj: any) => obj.ios; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.fetchProducts as jest.Mock) = jest .fn() .mockResolvedValue([{platform: 'ios', id: 'legacy'}]); await expect( - fetchProducts({skus: ['legacy'], type: 'inapp' as any}), + // @ts-expect-error the removed alias reaches the runtime check + fetchProducts({skus: ['legacy'], type: 'inapp'}), ).rejects.toThrow(/Unsupported product type/); expect(ExpoIapModule.fetchProducts).not.toHaveBeenCalled(); }); it('returns results unchanged when querying all product types', async () => { - (Platform as any).OS = 'ios'; - (Platform as any).select = (obj: any) => obj.ios; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.fetchProducts as jest.Mock) = jest.fn().mockResolvedValue([ {platform: 'ios', id: 'a'}, {platform: 'ios', id: 'b'}, @@ -519,8 +529,7 @@ describe('Public API (index.ts)', () => { }); it('restores Android query diagnostics from the native error envelope', async () => { - (Platform as any).OS = 'android'; - (Platform as any).select = (obj: any) => obj.android; + Object.assign(Platform, {OS: 'android'}); const payload = { code: 'query-product', message: 'Failed to query products', @@ -545,11 +554,11 @@ describe('Public API (index.ts)', () => { describe('requestPurchase', () => { it('passes through iOS purchase params', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue({id: 'x'}); - const res: any = await requestPurchase({ + const res = await requestPurchase({ request: { apple: { sku: 'sku1', @@ -571,19 +580,20 @@ describe('Public API (index.ts)', () => { }); it('rejects query-only all on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest.fn(); await expect( requestPurchase({ request: {apple: {sku: 'skuX'}}, + // @ts-expect-error query-only type reaches the runtime check type: 'all', - } as any), + }), ).rejects.toThrow(/only supported for product queries/); expect(ExpoIapModule.requestPurchase).not.toHaveBeenCalled(); }); it('returns canonical iOS array purchases', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([{id: 'a', store: 'apple'}]); @@ -597,7 +607,7 @@ describe('Public API (index.ts)', () => { }); it('restores iOS purchase diagnostics from the native error envelope', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const payload = { code: 'sku-not-found', message: 'Product not found', @@ -628,7 +638,7 @@ describe('Public API (index.ts)', () => { }); it('returns empty array when iOS subs resolves null', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue(null); @@ -642,8 +652,7 @@ describe('Public API (index.ts)', () => { }); it('maps Android in-app request properly', async () => { - (Platform as any).OS = 'android'; - (Platform as any).select = (obj: any) => obj.android; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -660,7 +669,7 @@ describe('Public API (index.ts)', () => { }); it('maps Android subs request using subscriptionOffers', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -682,7 +691,12 @@ describe('Public API (index.ts)', () => { ); }); - it.each([ + it.each< + [ + ProductType, + RequestPurchaseAndroidProps & RequestSubscriptionAndroidProps, + ] + >([ ['in-app', {skus: ['coins'], subscriptionOffers: []}], [ 'in-app', @@ -698,46 +712,50 @@ describe('Public API (index.ts)', () => { ])( 'rejects branch-mismatched Android options for %s without native dispatch', async (type, google) => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest.fn(); await expect( - requestPurchase({request: {google} as any, type: type as any}), + requestPurchase({request: {google}, type}), ).rejects.toThrow(/must match the selected product type/); expect(ExpoIapModule.requestPurchase).not.toHaveBeenCalled(); }, ); it('iOS rejects when sku missing', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect( - requestPurchase({request: {apple: {}} as any, type: 'in-app'} as any), + // @ts-expect-error a missing sku reaches the runtime check + requestPurchase({request: {apple: {}}, type: 'in-app'}), ).rejects.toMatchObject({code: ErrorCode.EmptySkuList}); }); it('Android rejects when skus missing', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( - requestPurchase({request: {google: {}} as any, type: 'in-app'} as any), + // @ts-expect-error missing skus reach the runtime check + requestPurchase({request: {google: {}}, type: 'in-app'}), ).rejects.toThrow(/skus/); }); it('Android invalid type throws', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( requestPurchase({ - request: {google: {skus: ['x']}} as any, - type: 'other' as any, + request: {google: {skus: ['x']}}, + // @ts-expect-error an unknown type reaches the runtime check + type: 'other', }), ).rejects.toThrow(/Unsupported product type/); }); it('Android rejects purchase requests for all product types', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( requestPurchase({ - request: {google: {skus: ['x']}} as any, - type: 'all' as any, + request: {google: {skus: ['x']}}, + // @ts-expect-error query-only type reaches the runtime check + type: 'all', }), ).rejects.toMatchObject({ code: ErrorCode.DeveloperError, @@ -746,17 +764,18 @@ describe('Public API (index.ts)', () => { }); it('Android subscription requests require skus array', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( requestPurchase({ - request: {google: {}} as any, + // @ts-expect-error missing skus reach the runtime check + request: {google: {}}, type: 'subs', }), ).rejects.toThrow(/The `skus` property is required/); }); it('Android subscription passes subscriptionProductReplacementParams to native module', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -790,7 +809,7 @@ describe('Public API (index.ts)', () => { }); it('Android subscription passes subscriptionProductReplacementParams with all replacement modes', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -832,7 +851,7 @@ describe('Public API (index.ts)', () => { }); it('Android subscription works without subscriptionProductReplacementParams (optional)', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -857,7 +876,7 @@ describe('Public API (index.ts)', () => { }); it('Android forwards minimal in-app Billing Choice options', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -880,7 +899,7 @@ describe('Public API (index.ts)', () => { }); it('Android forwards Billing Choice subscription replacement fields', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -915,7 +934,7 @@ describe('Public API (index.ts)', () => { }); it('Android subscription passes canonical replacement parameters', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -951,21 +970,21 @@ describe('Public API (index.ts)', () => { }); it('iOS maps withOffer through offerToRecordIOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const offer = { identifier: 'id', keyIdentifier: 'key', nonce: 'nonce', signature: 'sig', timestamp: 1234567890, - } as any; + }; (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue({id: 'x'}); await requestPurchase({ request: {apple: {sku: 'sku1', withOffer: offer}}, type: 'in-app', - } as any); + }); expect(ExpoIapModule.requestPurchase).toHaveBeenCalledWith({ type: 'in-app', request: { @@ -978,12 +997,12 @@ describe('Public API (index.ts)', () => { }); it('iOS passes advancedCommerceData for attribution tracking', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue({id: 'purchase-123'}); - const res: any = await requestPurchase({ + const res = await requestPurchase({ request: { apple: { sku: 'com.example.premium', @@ -1006,7 +1025,7 @@ describe('Public API (index.ts)', () => { }); it('iOS passes advancedCommerceData for subscription purchase', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([{id: 'sub-123', platform: 'ios'}]); @@ -1036,7 +1055,7 @@ describe('Public API (index.ts)', () => { }); it('iOS subscription passes advanced offer fields through', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([{id: 'sub-advanced', platform: 'ios'}]); @@ -1079,12 +1098,12 @@ describe('Public API (index.ts)', () => { }); it('iOS works without advancedCommerceData (optional field)', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue({id: 'purchase-no-acd'}); - const res: any = await requestPurchase({ + const res = await requestPurchase({ request: { apple: { sku: 'com.example.product', @@ -1105,7 +1124,7 @@ describe('Public API (index.ts)', () => { }); it('uses canonical apple without a compatibility warning', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue({id: 'canonical'}); @@ -1126,45 +1145,48 @@ describe('Public API (index.ts)', () => { }); it('rejects the removed ios request alias', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue({id: 'legacy'}); const request = { request: {ios: {sku: 'legacy-ios'}}, type: 'in-app', - } as any; + }; + // @ts-expect-error the removed alias reaches the runtime check await expect(requestPurchase(request)).rejects.toThrow(/sku/); expect(ExpoIapModule.requestPurchase).not.toHaveBeenCalled(); }); it('rejects the removed android request alias', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.requestPurchase as jest.Mock) = jest .fn() .mockResolvedValue([]); const request = { request: {android: {skus: ['legacy-android']}}, type: 'in-app', - } as any; + }; + // @ts-expect-error the removed alias reaches the runtime check await expect(requestPurchase(request)).rejects.toThrow(/skus/); expect(ExpoIapModule.requestPurchase).not.toHaveBeenCalled(); }); it('does not revive legacy ios when canonical apple is explicitly null', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); await expect( requestPurchase({ request: { apple: null, + // @ts-expect-error the removed alias reaches the runtime check ios: {sku: 'legacy-ios'}, }, type: 'in-app', - } as any), + }), ).rejects.toThrow(/sku/); expect(ExpoIapModule.requestPurchase).not.toHaveBeenCalled(); @@ -1173,17 +1195,18 @@ describe('Public API (index.ts)', () => { }); it('does not revive legacy android when canonical google is explicitly null', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); await expect( requestPurchase({ request: { google: null, + // @ts-expect-error the removed alias reaches the runtime check android: {skus: ['legacy-android']}, }, type: 'in-app', - } as any), + }), ).rejects.toThrow(/skus/); expect(ExpoIapModule.requestPurchase).not.toHaveBeenCalled(); @@ -1195,8 +1218,7 @@ describe('Public API (index.ts)', () => { describe('legacy wrappers and getters', () => { it('getAvailablePurchases: iOS and Android paths', async () => { // iOS path - (Platform as any).OS = 'ios'; - (Platform as any).select = (obj: any) => obj.ios; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValue([]); @@ -1207,8 +1229,7 @@ describe('Public API (index.ts)', () => { expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith(true, false); // Android path (unified getAvailableItems with options) - (Platform as any).OS = 'android'; - (Platform as any).select = (obj: any) => obj.android; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValueOnce([ @@ -1226,8 +1247,7 @@ describe('Public API (index.ts)', () => { }); it('getAvailablePurchases passes includeSuspendedAndroid option on Android', async () => { - (Platform as any).OS = 'android'; - (Platform as any).select = (obj: any) => obj.android; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValueOnce([ @@ -1247,11 +1267,8 @@ describe('Public API (index.ts)', () => { }); it('restorePurchases performs iOS sync then fetches purchases', async () => { - (Platform as any).OS = 'ios'; - (Platform as any).select = (obj: any) => obj.ios; - const syncSpy = jest - .spyOn(iosMod as any, 'syncIOS') - .mockResolvedValue(true); + Object.assign(Platform, {OS: 'ios'}); + const syncSpy = jest.spyOn(iosMod, 'syncIOS').mockResolvedValue(true); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValue([ @@ -1266,11 +1283,8 @@ describe('Public API (index.ts)', () => { }); it('restorePurchases uses native Onside restore when active', async () => { - (Platform as any).OS = 'ios'; - (Platform as any).select = (obj: any) => obj.ios; - const syncSpy = jest - .spyOn(iosMod as any, 'syncIOS') - .mockResolvedValue(true); + Object.assign(Platform, {OS: 'ios'}); + const syncSpy = jest.spyOn(iosMod, 'syncIOS').mockResolvedValue(true); Object.defineProperty(ExpoIapModule, 'USING_ONSIDE_SDK', { configurable: true, value: true, @@ -1297,12 +1311,12 @@ describe('Public API (index.ts)', () => { true, ); } finally { - delete (ExpoIapModule as any).USING_ONSIDE_SDK; + Reflect.deleteProperty(ExpoIapModule, 'USING_ONSIDE_SDK'); } }); it('getAvailablePurchases rejects mixed malformed results atomically', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValue([nativePurchase('valid'), {id: 'malformed'}]); @@ -1313,7 +1327,7 @@ describe('Public API (index.ts)', () => { }); it('getAvailablePurchases rejects a foreign store on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValue([ @@ -1329,7 +1343,7 @@ describe('Public API (index.ts)', () => { }); it('getAvailablePurchases rejects a foreign store on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValue([ @@ -1345,9 +1359,9 @@ describe('Public API (index.ts)', () => { }); it('restorePurchases propagates iOS sync failure without querying', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const syncError = new Error('sync failed'); - jest.spyOn(iosMod as any, 'syncIOS').mockRejectedValue(syncError); + jest.spyOn(iosMod, 'syncIOS').mockRejectedValue(syncError); (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); await expect(restorePurchases()).rejects.toBe(syncError); @@ -1355,8 +1369,8 @@ describe('Public API (index.ts)', () => { }); it('restorePurchases rejects a false iOS sync result', async () => { - (Platform as any).OS = 'ios'; - jest.spyOn(iosMod as any, 'syncIOS').mockResolvedValue(false); + Object.assign(Platform, {OS: 'ios'}); + jest.spyOn(iosMod, 'syncIOS').mockResolvedValue(false); (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); await expect(restorePurchases()).rejects.toMatchObject({ @@ -1368,9 +1382,8 @@ describe('Public API (index.ts)', () => { describe('finishTransaction', () => { it('iOS forwards purchase payload to native finishTransaction', async () => { - (Platform as any).OS = 'ios'; - (Platform as any).select = (obj: any) => obj.ios; - const basePurchase = { + Object.assign(Platform, {OS: 'ios'}); + const basePurchase: PurchaseInput = { store: 'apple', productId: 'prod.ios', isAutoRenewing: false, @@ -1380,12 +1393,12 @@ describe('Public API (index.ts)', () => { transactionDate: Date.now(), id: 'transaction-identifier', transactionId: 'transaction-identifier', - } as PurchaseInput; + }; (ExpoIapModule.finishTransaction as jest.Mock) = jest .fn() .mockResolvedValue(true); await expect( - finishTransaction({purchase: basePurchase as any}), + finishTransaction({purchase: basePurchase}), ).resolves.toBeUndefined(); expect(ExpoIapModule.finishTransaction).toHaveBeenCalledWith( basePurchase, @@ -1403,8 +1416,7 @@ describe('Public API (index.ts)', () => { }); it('Android consume vs acknowledge flows', async () => { - (Platform as any).OS = 'android'; - (Platform as any).select = (obj: any) => obj.android; + Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.consumePurchaseAndroid as jest.Mock) = jest .fn() .mockResolvedValue({responseCode: 0}); @@ -1412,8 +1424,8 @@ describe('Public API (index.ts)', () => { .fn() .mockResolvedValue({responseCode: 0}); - const basePurchase = { - platform: 'android', + const basePurchase: PurchaseInput = { + store: 'google', productId: 'p', isAutoRenewing: false, purchaseState: 'purchased', @@ -1424,13 +1436,13 @@ describe('Public API (index.ts)', () => { }; await finishTransaction({ - purchase: basePurchase as any, + purchase: basePurchase, isConsumable: true, }); expect(ExpoIapModule.consumePurchaseAndroid).toHaveBeenCalledWith('t'); await finishTransaction({ - purchase: basePurchase as any, + purchase: basePurchase, isConsumable: false, }); expect(ExpoIapModule.acknowledgePurchaseAndroid).toHaveBeenCalledWith( @@ -1442,14 +1454,14 @@ describe('Public API (index.ts)', () => { (ExpoIapModule.acknowledgePurchaseAndroid as jest.Mock).mockClear(); const p = finishTransaction({ purchase: { - platform: 'android', + store: 'google', productId: 'p', isAutoRenewing: false, purchaseState: 'purchased', quantity: 1, transactionDate: Date.now(), id: 'txn-missing-token', - } as any, + }, }); await expect(p).rejects.toMatchObject({ message: expect.stringMatching(/Purchase token/i), @@ -1459,67 +1471,68 @@ describe('Public API (index.ts)', () => { }); it('finishTransaction rejects on unsupported platform', async () => { - const originalOs = (Platform as any).OS; - (Platform as any).OS = 'web'; + const originalOs = Platform.OS; + Object.assign(Platform, {OS: 'web'}); await expect( finishTransaction({ purchase: { id: 'tid', - platform: 'web', + store: 'unknown', productId: 'prod.web', isAutoRenewing: false, purchaseState: 'purchased', purchaseToken: 'token', quantity: 1, transactionDate: Date.now(), - } as any, + }, }), ).rejects.toThrow(/Unsupported platform/); - (Platform as any).OS = originalOs; + Object.assign(Platform, {OS: originalOs}); }); }); describe('storefront', () => { it('getStorefront delegates to native getStorefront method', async () => { const nativeSpy = jest.fn().mockResolvedValue('US'); - (ExpoIapModule as any).getStorefront = nativeSpy; + ExpoIapModule.getStorefront = nativeSpy; const res = await getStorefront(); expect(nativeSpy).toHaveBeenCalledTimes(1); expect(res).toBe('US'); - delete (ExpoIapModule as any).getStorefront; + delete ExpoIapModule.getStorefront; }); it('getStorefront supports synchronous native responses', async () => { const nativeSpy = jest.fn().mockReturnValue('CA'); - (ExpoIapModule as any).getStorefront = nativeSpy; + ExpoIapModule.getStorefront = nativeSpy; const res = await getStorefront(); expect(nativeSpy).toHaveBeenCalledTimes(1); expect(res).toBe('CA'); - delete (ExpoIapModule as any).getStorefront; + delete ExpoIapModule.getStorefront; }); it.each([null, undefined, '', ' '])( 'getStorefront rejects an empty native value (%p)', async (value) => { - (ExpoIapModule as any).getStorefront = jest.fn(() => value); + // @ts-expect-error empty native values reach the runtime validation + ExpoIapModule.getStorefront = jest.fn(() => value); await expect(getStorefront()).rejects.toMatchObject({ code: ErrorCode.ServiceError, message: expect.stringContaining('no country code'), }); - delete (ExpoIapModule as any).getStorefront; + delete ExpoIapModule.getStorefront; }, ); it('getStorefront rejects when the native method is missing', async () => { - delete (ExpoIapModule as any).getStorefront; + delete ExpoIapModule.getStorefront; await expect(getStorefront()).rejects.toMatchObject({ code: ErrorCode.FeatureNotSupported, @@ -1528,7 +1541,7 @@ describe('Public API (index.ts)', () => { }); it('getStorefront normalizes native exceptions', async () => { - (ExpoIapModule as any).getStorefront = jest.fn(() => { + ExpoIapModule.getStorefront = jest.fn(() => { throw new Error('storefront exploded'); }); @@ -1537,11 +1550,11 @@ describe('Public API (index.ts)', () => { debugMessage: 'storefront exploded', }); - delete (ExpoIapModule as any).getStorefront; + delete ExpoIapModule.getStorefront; }); it('getStorefront rejects unsupported platforms', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(getStorefront()).rejects.toMatchObject({ code: ErrorCode.FeatureNotSupported, @@ -1552,24 +1565,24 @@ describe('Public API (index.ts)', () => { describe('deep link', () => { it('deepLinkToSubscriptions iOS delegates, Android validates', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const iosSpy = jest - .spyOn(iosMod as any, 'deepLinkToSubscriptionsIOS') - .mockResolvedValue(undefined as any); + .spyOn(iosMod, 'deepLinkToSubscriptionsIOS') + .mockResolvedValue(undefined); await deepLinkToSubscriptions({}); expect(iosSpy).toHaveBeenCalled(); iosSpy.mockRestore(); - (Platform as any).OS = 'android'; - await expect(deepLinkToSubscriptions({} as any)).rejects.toThrow( + Object.assign(Platform, {OS: 'android'}); + await expect(deepLinkToSubscriptions({})).rejects.toThrow( + 'packageName is required', + ); + await expect(deepLinkToSubscriptions({skuAndroid: 's'})).rejects.toThrow( 'packageName is required', ); - await expect( - deepLinkToSubscriptions({skuAndroid: 's'} as any), - ).rejects.toThrow('packageName is required'); const andSpy = jest - .spyOn(androidMod as any, 'deepLinkToSubscriptionsAndroid') - .mockResolvedValue(undefined as any); + .spyOn(androidMod, 'deepLinkToSubscriptionsAndroid') + .mockResolvedValue(undefined); await deepLinkToSubscriptions({ skuAndroid: 's', packageNameAndroid: 'com.app', @@ -1582,7 +1595,7 @@ describe('Public API (index.ts)', () => { }); it('deepLinkToSubscriptions rejects on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect( deepLinkToSubscriptions({ skuAndroid: 's', @@ -1592,7 +1605,7 @@ describe('Public API (index.ts)', () => { }); it('openRedeemOfferCode resolves the iOS redemption result', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const purchase = nativePurchase('redeemed', {store: 'apple'}); ( ExpoIapModule.presentCodeRedemptionSheetIOS as jest.Mock @@ -1606,7 +1619,7 @@ describe('Public API (index.ts)', () => { }); it('openRedeemOfferCode maps the Android launch result to null', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); ( ExpoIapModule.openRedeemOfferCodeAndroid as jest.Mock ).mockResolvedValueOnce(true); @@ -1615,29 +1628,29 @@ describe('Public API (index.ts)', () => { }); it('openRedeemOfferCode resolves null on Vega without launching', async () => { - (Platform as any).OS = 'kepler'; + Object.assign(Platform, {OS: 'kepler'}); await expect(openRedeemOfferCode()).resolves.toBeNull(); expect(ExpoIapModule.openRedeemOfferCodeAndroid).not.toHaveBeenCalled(); }); it('openRedeemOfferCode rejects on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(openRedeemOfferCode()).rejects.toThrow( /Unsupported platform: web/, ); }); it('requestPurchase rejects on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect( - requestPurchase({request: {} as any} as any), + requestPurchase({request: {}, type: 'in-app'}), ).rejects.toThrow(/Unsupported platform/); }); }); describe('getAvailablePurchases platform support', () => { it('rejects on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(getAvailablePurchases()).rejects.toThrow( /Unsupported platform: web/, @@ -1751,13 +1764,13 @@ describe('Public API (index.ts)', () => { const result = await getActiveSubscriptions(['premium_monthly']); expect(result).toEqual(mockIOSSubscription); - expect(result[0].renewalInfoIOS?.pendingUpgradeProductId).toBe( + expect(result[0]?.renewalInfoIOS?.pendingUpgradeProductId).toBe( 'premium_yearly', ); }); it('handles Android subscriptions with autoRenewingAndroid', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockAndroidSubscription = [ { productId: 'premium_monthly', @@ -1776,11 +1789,11 @@ describe('Public API (index.ts)', () => { const result = await getActiveSubscriptions(); expect(result).toEqual(mockAndroidSubscription); - expect(result[0].autoRenewingAndroid).toBe(false); + expect(result[0]?.autoRenewingAndroid).toBe(false); }); it('rejects on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(getActiveSubscriptions()).rejects.toThrow( /Unsupported platform: web/, @@ -1884,7 +1897,7 @@ describe('Public API (index.ts)', () => { }); it('rejects on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(hasActiveSubscriptions()).rejects.toThrow( /Unsupported platform: web/, @@ -1898,7 +1911,7 @@ describe('Public API (index.ts)', () => { }); it('calls native module on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = {isValid: true, receiptData: 'data'}; (ExpoIapModule.verifyPurchase as jest.Mock) = jest .fn() @@ -1915,7 +1928,7 @@ describe('Public API (index.ts)', () => { }); it('calls native module on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockResult = {isValid: true}; (ExpoIapModule.verifyPurchase as jest.Mock) = jest .fn() @@ -1942,7 +1955,7 @@ describe('Public API (index.ts)', () => { }); it('forwards Horizon verification options to the native module', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockResult = {isValid: true, success: true}; (ExpoIapModule.verifyPurchase as jest.Mock) = jest .fn() @@ -1962,7 +1975,7 @@ describe('Public API (index.ts)', () => { }); it('throws on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect( verifyPurchase({apple: {sku: 'com.example.product'}}), @@ -1976,7 +1989,7 @@ describe('Public API (index.ts)', () => { }); it('calls native module with IAPKit provider on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { provider: 'iapkit', iapkit: { @@ -2019,7 +2032,7 @@ describe('Public API (index.ts)', () => { }); it('calls native module on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockResult = { provider: 'iapkit', iapkit: { @@ -2065,7 +2078,7 @@ describe('Public API (index.ts)', () => { }); it('throws on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect( verifyPurchaseWithProvider({ @@ -2080,7 +2093,7 @@ describe('Public API (index.ts)', () => { }); it('handles verification failure response', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { provider: 'iapkit', iapkit: {isValid: false, state: 'inauthentic', store: 'apple'}, @@ -2103,7 +2116,7 @@ describe('Public API (index.ts)', () => { }); it('handles various IAPKit purchase states', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const states = [ 'entitled', 'pending-acknowledgment', diff --git a/libraries/expo-iap/src/__tests__/standardized-offer-types.test.ts b/libraries/expo-iap/src/__tests__/standardized-offer-types.test.ts index b11be53a4..8afc8cfca 100644 --- a/libraries/expo-iap/src/__tests__/standardized-offer-types.test.ts +++ b/libraries/expo-iap/src/__tests__/standardized-offer-types.test.ts @@ -390,9 +390,8 @@ describe('Standardized Offer Types', () => { describe('RequestPurchaseAndroidProps with offerTokenAndroid', () => { it('should support offerTokenAndroid for one-time purchase discounts', () => { - // This tests the type structure for one-time purchase discount offers - // introduced in Google Play Billing Library 8.0 - // Note: Input fields no longer have Android suffix (parent type indicates platform) + // One-time purchase discount offers (Google Play Billing Library 8.0). + // Input fields have no Android suffix; the parent type indicates the platform. const purchaseRequest = { skus: ['premium_upgrade'], offerToken: 'discount_offer_token_abc123', diff --git a/libraries/expo-iap/src/__tests__/tsconfig.json b/libraries/expo-iap/src/__tests__/tsconfig.json index a5258a476..1e06ab648 100644 --- a/libraries/expo-iap/src/__tests__/tsconfig.json +++ b/libraries/expo-iap/src/__tests__/tsconfig.json @@ -1,8 +1,6 @@ -// Editor-only project for the Jest specs in this directory. The package -// tsconfig excludes __tests__ so expo-module-scripts never emits test files -// into build/, but that leaves VS Code without a project for them and every -// jest global errors with ts(2708). This config re-attaches the directory to -// the package compiler options; ts-jest still type-checks specs at test time. +// Editor-only: gives VS Code a project for these specs, which the package +// tsconfig excludes so expo-module-scripts keeps them out of build/. Without it, +// every Jest global errors with ts(2708). ts-jest type-checks specs at test time. { "extends": "../../tsconfig.json", "compilerOptions": { diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 4af384d2b..b64968e0f 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -53,14 +53,12 @@ import { type PurchaseErrorProps, } from './utils/errorMapping'; -// Export all types export * from './types'; export * from './modules/android'; export * from './modules/ios'; export * from './onside'; export * from './vega'; -// Get the native constant value export enum OpenIapEvent { PurchaseUpdated = 'purchase-updated', PurchaseError = 'purchase-error', @@ -71,11 +69,7 @@ export enum OpenIapEvent { * developer billing flows. Nullable fields depend on the selected flow. */ DeveloperProvidedBillingAndroid = 'developer-provided-billing-android', - /** - * Fired when a subscription enters a billing-issue state (cross-platform). - * Unifies StoreKit 2 `Message.Reason.billingIssue` (iOS / Mac Catalyst 16.4+, visionOS 1.0+) and Play Billing 8.1+ - * `Purchase.isSuspended`. NOT fired on the Meta Horizon flavor. - */ + /** Fired when a subscription enters a billing-issue state; see `subscriptionBillingIssueListener`. */ SubscriptionBillingIssue = 'subscription-billing-issue', } @@ -106,12 +100,6 @@ type ExpoIapEmitter = { ): void; }; -type NativePurchaseUpdatedOptionsModule = { - setPurchaseUpdatedListenerOptions?: ( - options?: PurchaseUpdatedListenerOptions | null, - ) => Promise; -}; - const isStorePlatform = (): boolean => Platform.OS === 'ios' || Platform.OS === 'android'; @@ -121,10 +109,8 @@ const isStoreRuntime = (): boolean => const unsupportedPlatformError = (): Error => new Error(`Unsupported platform: ${Platform.OS}`); -// Use the raw native module for listener calls — JSI HostObjects require the -// real native module as `this` when calling addListener. Using a Proxy as -// `this` triggers "native state unsupported on Proxy" on New Architecture / Hermes. -// Resolved lazily so importing this module doesn't throw on unsupported platforms. +// Listener calls go to the raw native module (see getNativeModule), resolved +// lazily so importing this module doesn't throw on unsupported platforms. export const emitter: ExpoIapEmitter = { addListener(eventName, listener) { const nativeModule = getNativeModule(); @@ -218,8 +204,7 @@ const configurePurchaseUpdatedListenerOptionsIOS = ( ) => { if (Platform.OS !== 'ios') return; - const nativeModule = getNativeModule() as NativePurchaseUpdatedOptionsModule; - const promise = nativeModule.setPurchaseUpdatedListenerOptions?.({ + const promise = getNativeModule().setPurchaseUpdatedListenerOptions?.({ dedupeTransactionIOS, }); void promise?.catch((error: unknown) => { @@ -469,8 +454,6 @@ export const userChoiceBillingListenerAndroid = ( * This fires when a user selects the developer's option in an External Payments * or Billing Choice purchase flow. * - * Requires Google Play Billing Library 8.3.0+; Billing Choice fields require 9.1.0+. - * * @param listener - Callback that receives selected products and flow details * @returns EventSubscription that can be used to unsubscribe * @@ -487,7 +470,7 @@ export const userChoiceBillingListenerAndroid = ( * subscription.remove(); * ``` * - * @platform Android (8.3.0+; Billing Choice 9.1.0+) + * @platform Android (Play Billing Library 8.3.0+; Billing Choice 9.1.0+) */ export const developerProvidedBillingListenerAndroid = ( listener: (details: DeveloperProvidedBillingDetailsAndroid) => void, @@ -511,7 +494,7 @@ export const developerProvidedBillingListenerAndroid = ( * for a payment problem. Unifies: * - iOS / Mac Catalyst 16.4+ and visionOS 1.0+: StoreKit 2 `Message.Reason.billingIssue`. * - Android (Play Billing 8.1+): when `Purchase.isSuspendedAndroid === true`. - * - Meta Horizon / iOS 17 / older platforms: never fires. + * - Meta Horizon, Amazon, macOS, tvOS, watchOS, and iOS before 16.4: never fires. * * Recommended UX: call `deepLinkToSubscriptions()` when this fires so the user * can update their payment method in the platform subscription center. @@ -632,7 +615,7 @@ const invokeNativeWithPurchaseError = async ( * @returns Promise resolving to a `FetchProductsResult` union — `Product[]` for `'in-app'`, * `ProductSubscription[]` for `'subs'`, or a mixed array for `'all'`. * @throws When the store rejects the request (empty `skus`, not connected, - * network/store error). Unknown SKUs are simply omitted from the result, not thrown. + * network/store error). Unknown SKUs are omitted from the result, not thrown. * * @example * ```ts @@ -642,8 +625,7 @@ const invokeNativeWithPurchaseError = async ( * }); * ``` * - * @remarks This is a regular promise-based call. Don't confuse with `request*` APIs - * (`requestPurchase`), which are event-based. + * @remarks Promise-based, unlike the event-based `request*` APIs such as `requestPurchase`. * * @see {@link https://openiap.dev/docs/apis/fetch-products} */ @@ -698,8 +680,7 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { if (canonical === 'subs') { return items as ProductSubscription[]; } - // For 'all' type, items contain both Product and ProductSubscription - // Return as ProductOrSubscription[] to preserve discriminated union + // 'all' mixes products and subscriptions; returned as-is to keep the discriminated union. return items; }; @@ -735,8 +716,8 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { }; /** - * List the user's unfinished purchases — non-consumables, active subscriptions, and any - * pending transactions not yet finished. + * List the user's unfinished purchases: non-consumables, active subscriptions, + * and pending transactions. * * @param options Optional `PurchaseOptions`. iOS-only flags: * `alsoPublishToEventListenerIOS`, `onlyIncludeActiveItemsIOS`. @@ -756,12 +737,12 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { export const getAvailablePurchases: QueryField< 'getAvailablePurchases' > = async (options) => { - const normalizedOptions: PurchaseOptions = { + const normalizedOptions = { alsoPublishToEventListenerIOS: options?.alsoPublishToEventListenerIOS ?? false, onlyIncludeActiveItemsIOS: options?.onlyIncludeActiveItemsIOS ?? true, includeSuspendedAndroid: options?.includeSuspendedAndroid ?? false, - }; + } satisfies PurchaseOptions; if (isVegaOS()) { const purchases = await ExpoIapModule.getAvailableItems(normalizedOptions); @@ -789,13 +770,9 @@ export const getAvailablePurchases: QueryField< }; /** - * Get all active subscriptions with detailed information. - * Uses native OpenIAP module for accurate subscription status and renewal info. - * - * On iOS: Returns subscriptions with renewalInfoIOS containing pendingUpgradeProductId, - * willAutoRenew, autoRenewPreference, and other renewal details. - * - * On Android: Filters available purchases to find active subscriptions (fallback implementation). + * Get all active subscriptions. On iOS each entry carries `renewalInfoIOS` + * (e.g. pendingUpgradeProductId, willAutoRenew, autoRenewPreference); on + * Android they are filtered from the available purchases. * * @param subscriptionIds - Optional array of subscription product IDs to filter. If not provided, returns all active subscriptions. * @returns Promise resolving to array of active subscriptions with details @@ -903,9 +880,6 @@ export const getStorefront: QueryField<'getStorefront'> = async () => { return storefront; }; -/** - * Helper to normalize request props to platform-specific format - */ function normalizeRequestProps( request: RequestPurchasePropsByPlatforms, platform: 'ios', @@ -960,14 +934,15 @@ function validateAndroidPurchaseBranchOptions( } /** - * Initiate a purchase or subscription flow. The result is delivered through - * `purchaseUpdatedListener` — NOT the return value. + * Initiate a purchase or subscription flow. The result arrives through + * `purchaseUpdatedListener` / `purchaseErrorListener` (or `useIAP`'s + * `onPurchaseSuccess` / `onPurchaseError`), not the return value. * * @param args `RequestPurchaseProps`, discriminated by `type`: * - `type: 'in-app'` — pass `request.apple.sku` (iOS) and/or `request.google.skus` (Android). * - `type: 'subs'` — same shape, plus `request.google.subscriptionOffers: [{ sku, offerToken }]`. - * @returns The dispatched purchase payload. **Do not rely on it** for the actual outcome. - * @throws Synchronous rejection from the store (e.g. `E_NOT_PREPARED`, validation failure). + * @returns The dispatched purchase payload; do not rely on it for the outcome. + * @throws Synchronous rejection from the store (e.g. `ErrorCode.NotPrepared`, validation failure). * * @example * ```ts @@ -980,9 +955,6 @@ function validateAndroidPurchaseBranchOptions( * }); * ``` * - * @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} / - * {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`. - * * @see {@link https://openiap.dev/docs/apis/request-purchase} */ export const requestPurchase: MutationField<'requestPurchase'> = async ( @@ -1209,7 +1181,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( * } * ``` * - * @remarks **Critical:** Android purchases must be finalized within 3 days or Google + * @remarks Android purchases must be finalized within 3 days or Google * auto-refunds. iOS unfinished transactions replay on every app launch. * * @see {@link https://openiap.dev/docs/apis/finish-transaction} @@ -1248,14 +1220,11 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({ }; /** - * Restore completed transactions (cross-platform behavior) + * Restore completed transactions. Returns nothing; read the restored items with + * `getAvailablePurchases` or from hook state. * - * - iOS: perform a lightweight sync, or Onside restore when OnsideKit is active, - * then fetch available purchases to surface restored items to the app. - * - Android: simply fetch available purchases (restoration happens via query). - * - * This helper triggers the refresh flows but does not return the purchases; consumers should - * call `getAvailablePurchases` or rely on hook state to inspect the latest items. + * - iOS: sync (or Onside restore when OnsideKit is active), then fetch available purchases. + * - Android: fetch available purchases; the query itself restores them. * * @see {@link https://openiap.dev/docs/apis/restore-purchases} */ @@ -1335,10 +1304,7 @@ export const openRedeemOfferCode: MutationField< }; /** - * Verify purchase with the configured providers - * - * This function uses the native OpenIAP verifyPurchase implementation - * which validates purchases using platform-specific methods. + * Verify purchase with the configured providers. * * @param options - Receipt validation options containing the SKU * @returns Promise resolving to receipt validation result @@ -1356,10 +1322,7 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( }; /** - * Verify purchase with a specific provider (e.g., IAPKit) - * - * This function allows you to verify purchases using external verification - * services like IAPKit, which provide additional validation and security. + * Verify purchase with a specific provider (e.g., IAPKit). * * @param options - Verification options including provider and credentials * @returns Promise resolving to provider-specific verification result diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts index 2389bfae6..8315c281f 100644 --- a/libraries/expo-iap/src/kit-api.ts +++ b/libraries/expo-iap/src/kit-api.ts @@ -1,14 +1,11 @@ -// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS -// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of -// `packages/mcp-server/src/kit-client.ts` so the same operations are -// reachable from both LLM tools and end-user apps without each -// duplicating the URL layout. +// Fetch wrapper for kit's `/v1` API, used by react-native-iap and expo-iap. +// It mirrors `packages/mcp-server/src/kit-client.ts`, so both share one URL +// layout. export type KitApiOptions = { apiKey: string; baseUrl?: string; - // Optional fetch override for runtimes without a global (older RN - // builds) or for injection in tests. + // For runtimes without a global fetch, or for tests. fetchImpl?: (input: string, init?: RequestInit) => Promise; /** Optional AsyncStorage-compatible persistent cache for direct client * payload reads. Cache failures never change a successful API result. */ @@ -95,11 +92,11 @@ export type KitProduct = { title: string; description?: string; baseLocale?: string; - localizations?: Array<{ + localizations?: { locale: string; title: string; description?: string; - }>; + }[]; regions?: "all" | string[]; priceAmountMicros?: number; currency?: string; @@ -154,7 +151,7 @@ export type KitMetricsResponse = { }; export type KitRevenueMetricsResponse = { - days: Array<{ + days: { day: string; currency: string; productId: string; @@ -165,7 +162,7 @@ export type KitRevenueMetricsResponse = { cancellations: number; refunds: number; revenueMicros: number; - }>; + }[]; currencies: string[]; productIds: string[]; platforms: KitProductPlatform[]; @@ -208,19 +205,19 @@ export type KitProductSyncJobResponse = { pulled: number; pushed: number; deleted?: number; - failures: Array<{ productId: string; reason: string }>; + failures: { productId: string; reason: string }[]; failuresTruncated?: boolean; - plannedWrites?: Array<{ + plannedWrites?: { productId: string; step: string; detail?: string; - }>; + }[]; plannedWritesTruncated?: boolean; - manualActions?: Array<{ + manualActions?: { productId: string; code: string; message: string; - }>; + }[]; manualActionsTruncated?: boolean; }; error?: string; @@ -246,11 +243,9 @@ type InternalRequestInit = Omit & { const DEFAULT_BASE_URL = "https://kit.openiap.dev"; -// Merge the request's internal headers with kit defaults (`accept`, -// optionally `content-type`). When `Headers` is missing — older React -// Native builds where the operator wires up `fetchImpl` without a -// `Headers` polyfill — the internal request sites use plain records, -// so a small case-insensitive merge is sufficient. +// Adds kit's defaults (`accept`, and `content-type` for a body) unless the +// request set them. Some React Native runtimes have fetch but no global +// `Headers`, so it falls back to a case-insensitive merge into a plain record. function mergeHeaders( callerHeaders: Record | undefined, hasBody: boolean, @@ -263,8 +258,6 @@ function mergeHeaders( } return merged; } - // Plain-object fallback path. Build a case-insensitive name map and - // re-emit it as a record `fetchImpl` accepts. const lower = new Map(); const setIfAbsent = (name: string, value: string) => { const key = name.toLowerCase(); @@ -310,20 +303,8 @@ export function kitApi(options: KitApiOptions) { path: string, init?: InternalRequestInit, ): Promise { - // Normalize headers without depending on a global `Headers` - // constructor: older React Native runtimes ship `fetch` (or a - // polyfill via `fetchImpl`) without exposing `Headers` globally. - // The prior implementation crashed before the first request on - // those runtimes. We use `new Headers()` when available and - // otherwise fall back to a small case-insensitive merge into a - // plain record. Either way, kit defaults only apply when the - // internal request hasn't set the same name. const headers = mergeHeaders(init?.headers, init?.body != null); - // Prepend a leading slash if `path` is missing one. Today's - // call sites all hard-code the leading "/", but normalizing here - // makes the helper safe for future additions and matches the - // already-stripped `baseUrl` (PR #124 - // (https://github.com/hyodotdev/openiap/pull/124) review). + // baseUrl has its trailing slash stripped, so the path needs a leading one. const normalizedPath = path.startsWith("/") ? path : `/${path}`; return fetchImpl(`${baseUrl}${normalizedPath}`, { ...init, @@ -336,26 +317,20 @@ export function kitApi(options: KitApiOptions) { path: string, ): Promise { const text = await response.text(); - // Empty body normalizes to null so callers expecting JSON - // (status / entitlements / list*) don't get a truthy "" - // and crash on property access. + // An empty body parses as null, not "". let parsed: unknown = null; let parseError: unknown = null; if (text) { try { parsed = JSON.parse(text); } catch (error) { - // Non-JSON body (a misconfigured proxy returning HTML, a - // CDN-injected error page, etc.) on a 2xx response would - // otherwise reach the caller as `parsed = text` and crash - // on property access via `parsed as T`. Throw a structured - // KitApiError instead so callers see a typed failure. + // A 2xx with a non-JSON body (a proxy's HTML error page, say) must + // fail as a KitApiError, not reach the caller as text typed as T. parseError = error; } } if (!response.ok) { - // Surface the raw body (text or parsed) on the error path so - // operators can read the upstream error message verbatim. + // Keep the raw body so the upstream error message stays readable. throw new KitApiError( response.status, parsed ?? text, diff --git a/libraries/expo-iap/src/modules/__tests__/android.test.ts b/libraries/expo-iap/src/modules/__tests__/android.test.ts index f22381ca4..4ef876a82 100644 --- a/libraries/expo-iap/src/modules/__tests__/android.test.ts +++ b/libraries/expo-iap/src/modules/__tests__/android.test.ts @@ -43,9 +43,9 @@ describe('Android Module Functions', () => { describe('Type Guards', () => { it('isProductAndroid should correctly identify Android products', () => { - const androidProduct = {platform: 'android', id: 'p1'} as any; - const iosProduct = {platform: 'ios', id: 'p1'} as any; - const invalidProduct = {id: 'p1'} as any; + const androidProduct = {platform: 'android', id: 'p1'}; + const iosProduct = {platform: 'ios', id: 'p1'}; + const invalidProduct = {id: 'p1'}; expect(isProductAndroid(androidProduct)).toBe(true); expect(isProductAndroid(iosProduct)).toBe(false); @@ -70,15 +70,15 @@ describe('Android Module Functions', () => { await expect( deepLinkToSubscriptionsAndroid({ skuAndroid: 'id', - packageNameAndroid: '' as any, + packageNameAndroid: '', }), ).rejects.toThrow('packageName is required'); }); it('delegates to native module when available', async () => { - const original = (ExpoIapModule as any).deepLinkToSubscriptionsAndroid; + const original = ExpoIapModule.deepLinkToSubscriptionsAndroid; const nativeFn = jest.fn().mockResolvedValue(undefined); - (ExpoIapModule as any).deepLinkToSubscriptionsAndroid = nativeFn; + ExpoIapModule.deepLinkToSubscriptionsAndroid = nativeFn; await deepLinkToSubscriptionsAndroid({ skuAndroid: 'monthly_premium', @@ -90,7 +90,7 @@ describe('Android Module Functions', () => { packageNameAndroid: 'com.example.app', }); - (ExpoIapModule as any).deepLinkToSubscriptionsAndroid = original; + ExpoIapModule.deepLinkToSubscriptionsAndroid = original; }); }); @@ -316,7 +316,8 @@ describe('Android Module Functions', () => { ExpoIapModule.getBillingChoiceInfoAndroid as jest.Mock ).mockResolvedValue(mockResult); - const result = await (getBillingChoiceInfoAndroid as any)(); + // @ts-expect-error omitted params exercise the runtime defaults + const result = await getBillingChoiceInfoAndroid(); expect(ExpoIapModule.getBillingChoiceInfoAndroid).toHaveBeenCalledWith({ billingProgram: 'billing-choice', @@ -414,7 +415,8 @@ describe('Android Module Functions', () => { await expect( launchExternalLinkAndroid({ - billingProgram: '' as any, + // @ts-expect-error an empty program reaches the native validation + billingProgram: '', launchMode: 'launch-in-external-browser-or-app', linkType: 'link-to-digital-content-offer', linkUri: 'https://example.com/purchase', @@ -435,7 +437,7 @@ describe('Android Module Functions', () => { billingProgram: 'external-offer', launchMode: 'launch-in-external-browser-or-app', linkType: 'link-to-digital-content-offer', - linkUri: '' as any, + linkUri: '', }), ).rejects.toThrow('`linkUri` is a required and non-empty parameter.'); }); @@ -451,8 +453,9 @@ describe('Android Module Functions', () => { ExpoIapModule.createBillingProgramReportingDetailsAndroid as jest.Mock ).mockResolvedValue(mockResult); - const result = - await createBillingProgramReportingDetailsAndroid('external-offer'); + const result = await createBillingProgramReportingDetailsAndroid( + 'external-offer', + ); expect( ExpoIapModule.createBillingProgramReportingDetailsAndroid, diff --git a/libraries/expo-iap/src/modules/__tests__/ios.test.ts b/libraries/expo-iap/src/modules/__tests__/ios.test.ts index 365117b9c..d8fa06e13 100644 --- a/libraries/expo-iap/src/modules/__tests__/ios.test.ts +++ b/libraries/expo-iap/src/modules/__tests__/ios.test.ts @@ -287,13 +287,13 @@ describe('iOS Module Functions', () => { id: 'legacy-id', productId: mockSku, transactionId: 'txn-1', - } as any; + }; (ExpoIapModule.currentEntitlementIOS as jest.Mock).mockResolvedValue( mockEntitlement, ); - const result = (await currentEntitlementIOS(mockSku)) as any; + const result = await currentEntitlementIOS(mockSku); expect(ExpoIapModule.currentEntitlementIOS).toHaveBeenCalledWith(mockSku); expect(result?.id).toBe('legacy-id'); @@ -326,7 +326,7 @@ describe('iOS Module Functions', () => { mockTransaction, ); - const result = (await latestTransactionIOS(mockSku)) as any; + const result = await latestTransactionIOS(mockSku); expect(ExpoIapModule.latestTransactionIOS).toHaveBeenCalledWith(mockSku); expect(result?.id).toBe('com.example.product'); @@ -385,12 +385,12 @@ describe('iOS Module Functions', () => { }); it('should call showManageSubscriptionsIOS', async () => { - const mockPurchases: any[] = [validPurchase('txn-77')]; + const mockPurchases = [validPurchase('txn-77')]; (ExpoIapModule.showManageSubscriptionsIOS as jest.Mock).mockResolvedValue( mockPurchases, ); - const result = (await showManageSubscriptionsIOS()) as any[]; + const result = await showManageSubscriptionsIOS(); expect(ExpoIapModule.showManageSubscriptionsIOS).toHaveBeenCalledTimes(1); expect(Array.isArray(result)).toBe(true); @@ -543,7 +543,7 @@ describe('iOS Module Functions', () => { const result = await getPendingTransactionsIOS(); expect(ExpoIapModule.getPendingTransactionsIOS).toHaveBeenCalledTimes(1); - expect(result[0].id).toBe('txn-pending'); + expect(result[0]?.id).toBe('txn-pending'); }); it('clears iOS transactions', async () => { @@ -795,13 +795,15 @@ describe('iOS Module Functions', () => { it('should throw when tokenType missing', async () => { await expect( - getExternalPurchaseCustomLinkTokenIOS(undefined as any), + // @ts-expect-error runtime guard + getExternalPurchaseCustomLinkTokenIOS(undefined), ).rejects.toThrow(/requires a tokenType/); }); it('should throw when tokenType is empty string', async () => { await expect( - getExternalPurchaseCustomLinkTokenIOS('' as any), + // @ts-expect-error runtime guard + getExternalPurchaseCustomLinkTokenIOS(''), ).rejects.toThrow(/requires a tokenType/); }); @@ -860,13 +862,15 @@ describe('iOS Module Functions', () => { it('should throw when noticeType missing', async () => { await expect( - showExternalPurchaseCustomLinkNoticeIOS(undefined as any), + // @ts-expect-error runtime guard + showExternalPurchaseCustomLinkNoticeIOS(undefined), ).rejects.toThrow(/requires a noticeType/); }); it('should throw when noticeType is empty string', async () => { await expect( - showExternalPurchaseCustomLinkNoticeIOS('' as any), + // @ts-expect-error runtime guard + showExternalPurchaseCustomLinkNoticeIOS(''), ).rejects.toThrow(/requires a noticeType/); }); diff --git a/libraries/expo-iap/src/modules/__tests__/tsconfig.json b/libraries/expo-iap/src/modules/__tests__/tsconfig.json index 215877550..f83e3b2ba 100644 --- a/libraries/expo-iap/src/modules/__tests__/tsconfig.json +++ b/libraries/expo-iap/src/modules/__tests__/tsconfig.json @@ -1,8 +1,6 @@ -// Editor-only project for the Jest specs in this directory. The package -// tsconfig excludes __tests__ so expo-module-scripts never emits test files -// into build/, but that leaves VS Code without a project for them and every -// jest global errors with ts(2708). This config re-attaches the directory to -// the package compiler options; ts-jest still type-checks specs at test time. +// Editor-only: gives VS Code a project for these specs, which the package +// tsconfig excludes so expo-module-scripts keeps them out of build/. Without it, +// every Jest global errors with ts(2708). ts-jest type-checks specs at test time. { "extends": "../../../tsconfig.json", "compilerOptions": { diff --git a/libraries/expo-iap/src/modules/android.ts b/libraries/expo-iap/src/modules/android.ts index ee6715a52..33e3003fc 100644 --- a/libraries/expo-iap/src/modules/android.ts +++ b/libraries/expo-iap/src/modules/android.ts @@ -22,23 +22,10 @@ import type { QueryField, } from '../types'; -type NativeAndroidModule = { - deepLinkToSubscriptionsAndroid?: (params: { - skuAndroid?: string; - packageNameAndroid?: string; - }) => Promise | void; - getStorefront?: () => Promise | string; -}; - -const nativeAndroidModule = ExpoIapModule as NativeAndroidModule; - /** - * Enforce the documented Android-only contract. Vega OS is treated as an - * Android store runtime (matching `isAndroidStoreRuntime` in src/index.ts), - * so it passes through. Without this guard, calling a suffixed wrapper on - * another platform falls through to the native proxy and surfaces as an - * opaque `TypeError: ExpoIapModule. is not a function` instead of the - * promised platform error. + * Throws the documented Android-only error; Vega OS passes, as in + * `isAndroidStoreRuntime`. Without it, other platforms reach the native proxy + * and fail with an opaque `ExpoIapModule. is not a function`. */ const requireAndroidPlatform = (methodName: string): void => { if (Platform.OS !== 'android' && !isVegaOS()) { @@ -54,8 +41,8 @@ export function isProductAndroid( item != null && typeof item === 'object' && 'platform' in item && - typeof (item as any).platform === 'string' && - (item as any).platform.toLowerCase() === 'android' + typeof item.platform === 'string' && + item.platform.toLowerCase() === 'android' ); } @@ -81,8 +68,8 @@ export const deepLinkToSubscriptionsAndroid = async ( const packageName = options?.packageNameAndroid ?? undefined; // Prefer native deep link implementation via OpenIAP module - if (nativeAndroidModule?.deepLinkToSubscriptionsAndroid) { - return nativeAndroidModule.deepLinkToSubscriptionsAndroid({ + if (ExpoIapModule.deepLinkToSubscriptionsAndroid) { + return ExpoIapModule.deepLinkToSubscriptionsAndroid({ skuAndroid: sku, packageNameAndroid: packageName, }); @@ -168,12 +155,10 @@ export const acknowledgePurchaseAndroid: MutationField< }; /** - * Open the Google Play offer/promo code redemption flow so the user can enter a code (Android only). - * On Play builds, launches the Play Store redeem page. A listener can receive - * the purchase while the app has an active billing connection; reconcile - * available purchases when the app resumes. Unsupported store flavors return false. - * Does not require the billing client to be initialized (no Play Billing version requirement). - * Android counterpart of presentCodeRedemptionSheetIOS. + * Open the Play Store offer/promo code redeem page; other store flavors return + * false. Needs no initialized billing client or Play Billing version. A listener + * can receive the purchase while billing is connected; reconcile available + * purchases on resume. * * @returns Promise resolving to true when launched, or false when unsupported * diff --git a/libraries/expo-iap/src/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts index 95690342d..1add5bcc0 100644 --- a/libraries/expo-iap/src/modules/ios.ts +++ b/libraries/expo-iap/src/modules/ios.ts @@ -1,7 +1,4 @@ -// External dependencies - // Internal modules -// import removed: use purchaseUpdatedListener directly in app code import ExpoIapModule from '../ExpoIapModule'; // Types @@ -22,10 +19,8 @@ import {decodeApplePurchases} from '../utils/availablePurchases'; import {Linking, Platform} from 'react-native'; /** - * Enforce the documented iOS-only contract. Without this, calling a - * suffixed wrapper on another platform falls through to the native proxy - * and surfaces as an opaque `TypeError: ExpoIapModule. is not a - * function` instead of the promised platform error. + * Throws the documented iOS-only error. Without it, other platforms reach the + * native proxy and fail with an opaque `ExpoIapModule. is not a function`. */ const requireIosPlatform = (methodName: string): void => { if (Platform.OS !== 'ios') { @@ -38,8 +33,6 @@ export type TransactionEvent = { error?: PurchaseError; }; -// Listeners - // Type guards export function isProductIOS( item: unknown, @@ -48,8 +41,8 @@ export function isProductIOS( item != null && typeof item === 'object' && 'platform' in item && - typeof (item as any).platform === 'string' && - (item as any).platform.toLowerCase() === 'ios' + typeof item.platform === 'string' && + item.platform.toLowerCase() === 'ios' ); } @@ -58,7 +51,7 @@ export function isProductIOS( * Sync state with Appstore (iOS only) * https://developer.apple.com/documentation/storekit/appstore/3791906-sync * - * @returns Promise resolving to null on success + * @returns Promise resolving to true on success * @throws Error if called on non-iOS platform * * @platform iOS @@ -199,12 +192,8 @@ export const showManageSubscriptionsIOS: MutationField< }; /** - * Get the receipt data from the iOS device. - * This returns the base64 encoded receipt data which can be sent to your server - * for verification with Apple's server. - * - * NOTE: For proper security, always verify receipts on your server using - * Apple's verifyReceipt endpoint, not directly from the app. + * Get the device's base64 receipt data to send to your server. Verify it there + * with Apple's verifyReceipt endpoint, never directly from the app. * * @returns {Promise} Base64 encoded receipt data * @@ -216,11 +205,9 @@ export const getReceiptDataIOS: QueryField<'getReceiptDataIOS'> = async () => { }; /** - * Refresh the receipt data from Apple's servers and return the updated receipt. - * This calls AppStore.sync() before reading the receipt, ensuring the latest - * receipt data is available. Use this after a first purchase when - * getReceiptDataIOS() may return an empty string because the receipt file - * has not yet been written to disk. + * Refresh the receipt from Apple (AppStore.sync()) and return it. Use after a + * first purchase, when getReceiptDataIOS() can return an empty string because + * the receipt file is not written to disk yet. * * @returns {Promise} Base64 encoded receipt data * @@ -277,10 +264,7 @@ export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( }; /** - * Present the code redemption sheet for offer codes (iOS only). - * This allows users to redeem promotional codes for in-app purchases and subscriptions. - * - * Note: This only works on real devices, not simulators. + * Present the offer code redemption sheet. Real devices only, not simulators. * * @returns The verified redeemed purchase when built with Xcode 27+ and * running on Apple 27+. Earlier iOS/visionOS system sheets return null; @@ -303,12 +287,8 @@ export const presentCodeRedemptionSheetIOS: MutationField< }; /** - * Get app transaction information (iOS 16.0+). - * AppTransaction represents the initial purchase that unlocked the app. - * - * NOTE: This function requires: - * - iOS 16.0 or later at runtime - * - Xcode 15.0+ with iOS 16.0 SDK for compilation + * Get the AppTransaction: the initial purchase that unlocked the app. + * Requires iOS 16.0+ at runtime and Xcode 15.0+ (iOS 16.0 SDK) to compile. * * @returns Promise resolving to the app transaction information or null if not available * @throws Error if called on non-iOS platform, iOS version < 16.0, or compiled with older SDK @@ -400,10 +380,7 @@ export const deepLinkToSubscriptionsIOS = (): Promise => /** * Check if the device can present an external purchase notice sheet (iOS 17.4+). - * - * Wraps `ExternalPurchase.canPresent`, which Apple introduced in iOS 17.4. - * Note: the notice sheet itself (`presentExternalPurchaseNoticeSheetIOS`) - * still requires iOS 18.2+; only the eligibility check is available earlier. + * Wraps `ExternalPurchase.canPresent`. * * @returns Promise resolving to true if the notice sheet can be presented * @platform iOS @@ -418,7 +395,7 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< }; /** - * Present an external purchase notice sheet to inform users about external purchases (iOS 15.4+). + * Present an external purchase notice sheet to inform users about external purchases (iOS 17.4+). * This must be called before opening an external purchase link. * Returns the external purchase token when user continues. * @@ -454,7 +431,6 @@ export const presentExternalPurchaseLinkIOS: MutationField< /** * Check if app is eligible for ExternalPurchaseCustomLink API (iOS 18.1+). - * Returns true if the app can use custom external purchase links. * * @returns Promise resolving to true if eligible * @platform iOS @@ -489,14 +465,14 @@ export const getExternalPurchaseCustomLinkTokenIOS: QueryField< "getExternalPurchaseCustomLinkTokenIOS requires a tokenType ('acquisition' or 'services')", ); } - const result = - await ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS(tokenType); + const result = await ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS( + tokenType, + ); return result as ExternalPurchaseCustomLinkTokenResultIOS; }; /** * Show ExternalPurchaseCustomLink notice sheet (iOS 18.1+). - * Displays the system disclosure notice sheet for custom external purchase links. * Call this after a deliberate customer interaction before linking out to external purchases. * * @param noticeType - Notice type: 'browser' (external purchases displayed in browser) @@ -515,8 +491,9 @@ export const showExternalPurchaseCustomLinkNoticeIOS: MutationField< "showExternalPurchaseCustomLinkNoticeIOS requires a noticeType ('browser')", ); } - const result = - await ExpoIapModule.showExternalPurchaseCustomLinkNoticeIOS(noticeType); + const result = await ExpoIapModule.showExternalPurchaseCustomLinkNoticeIOS( + noticeType, + ); return result as ExternalPurchaseCustomLinkNoticeResultIOS; }; diff --git a/libraries/expo-iap/src/onside/index.ts b/libraries/expo-iap/src/onside/index.ts index 73def843d..ad67ab2b4 100644 --- a/libraries/expo-iap/src/onside/index.ts +++ b/libraries/expo-iap/src/onside/index.ts @@ -7,13 +7,13 @@ import {useEffect, useState} from 'react'; let installedFromOnside: InstalledFromOnside = null; /** - * IMPORTANT: - * Note: call it BEFORE initializing useIAP, for example during SplashScreen initialization. + * Detects an Onside marketplace install so the payment module can switch at runtime. * - * 1) Call checkInstallationFromOnside BEFORE initializing useIAP. - * Reason: this is an asynchronous check and cannot run during module import/initialization. - * Always reference useIAP after this check to ensure the correct platform is being used. - * 2) Make sure the Onside module is enabled in your Expo config plugin: + * Call it before initializing useIAP, for example during SplashScreen initialization: + * the check is asynchronous and cannot run at module import, and useIAP must be + * referenced only after it to use the correct platform. + * + * Enable the Onside module in your Expo config plugin: * * plugins: [ * [ @@ -27,10 +27,8 @@ let installedFromOnside: InstalledFromOnside = null; * ], * ]; * - * Without this, the Onside integration won’t be linked and the availability check will always be false. + * Without it, the Onside integration is not linked and the check always returns false. */ - -// checkInstallationFromOnside is required to switch the payment module at runtime based on marketplace installation. async function checkInstallationFromOnside(): Promise { const onsideInstallation = await ExpoOnsideMarketplaceAvailabilityModule.checkInstallationFromOnsideAsync(); diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts index 39990ce0f..1b9ef9ced 100644 --- a/libraries/expo-iap/src/useIAP.ts +++ b/libraries/expo-iap/src/useIAP.ts @@ -144,10 +144,8 @@ export interface UseIAPOptions { */ purchaseUpdatedListenerOptions?: PurchaseUpdatedListenerOptions | null; /** - * Callback for general errors from hook methods like fetchProducts, - * getAvailablePurchases, getActiveSubscriptions, restorePurchases, etc. - * These are Promise-based operations that can fail due to network issues - * or store unavailability. + * Called when a hook method such as fetchProducts, getAvailablePurchases, + * getActiveSubscriptions or restorePurchases fails. */ onError?: (error: Error) => void; onPromotedProductIOS?: (product: Product) => void; @@ -162,8 +160,7 @@ export interface UseIAPOptions { /** Fires when a subscription enters a billing-issue state. */ onSubscriptionBillingIssue?: (purchase: Purchase) => void; /** - * Enable a specific billing program for Android (8.2.0+) - * When set, enables the specified billing program for external transactions. + * Enable a specific billing program for Android (8.2.0+). * Use 'external-payments' for Developer Provided Billing (Japan only, 8.3.0+). * Use 'user-choice-billing' for User Choice Billing (7.0+). * Use 'billing-choice' for Billing Choice (9.1.0+). @@ -198,7 +195,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { const startedInitializationGenerationRef = useRef(0); const deliveredPurchaseKeysRef = useRef(new Set()); - // Helper function to merge arrays with duplicate checking const mergeWithDuplicateCheck = useCallback( ( existingItems: T[], @@ -293,7 +289,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { return true; }, []); - // Helper function to invoke onError callback const invokeOnError = useCallback((error: unknown) => { if (optionsRef.current?.onError) { optionsRef.current.onError( @@ -310,7 +305,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { * @returns Promise that resolves when the request is dispatched; results land in the * hook's reactive `products` / `subscriptions` state. * @throws When the store rejects the request (empty `skus`, not connected, - * network/store error). Unknown SKUs are simply omitted from the result, not thrown. + * network/store error). Unknown SKUs are omitted from the result, not thrown. * * @example * ```ts @@ -321,8 +316,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { * }); * ``` * - * @remarks This is a regular promise-based call. Don't confuse with `request*` APIs - * (`requestPurchase`), which are event-based. + * @remarks Promise-based, unlike the event-based `request*` APIs such as `requestPurchase`. * * @see {@link https://openiap.dev/docs/apis/fetch-products} */ @@ -397,8 +391,8 @@ export function useIAP(options?: UseIAPOptions): UseIap { ); /** - * List the user's unfinished purchases — non-consumables, active subscriptions, and any - * pending transactions not yet finished. + * List the user's unfinished purchases: non-consumables, active subscriptions, + * and pending transactions. * * @param options Optional `PurchaseOptions`. iOS-only flags: * `alsoPublishToEventListenerIOS`, `onlyIncludeActiveItemsIOS`. @@ -491,7 +485,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { * } * ``` * - * @remarks **Critical:** Android purchases must be finalized within 3 days or Google + * @remarks Android purchases must be finalized within 3 days or Google * auto-refunds. iOS unfinished transactions replay on every app launch. * * @see {@link https://openiap.dev/docs/apis/finish-transaction} @@ -532,15 +526,15 @@ export function useIAP(options?: UseIAPOptions): UseIap { ); /** - * Initiate a purchase or subscription flow. The result is delivered through - * `purchaseUpdatedListener` — NOT the return value. + * Initiate a purchase or subscription flow. The result arrives through + * `purchaseUpdatedListener` / `purchaseErrorListener`, not the return value. * * @param props `RequestPurchaseProps`, discriminated by `type`: * - `type: 'in-app'` — pass `request.apple.sku` (iOS) and/or `request.google.skus` (Android). * - `type: 'subs'` — same shape, plus `request.google.subscriptionOffers: [{ sku, offerToken }]`. * @returns Promise that resolves when the request is dispatched; the actual purchase * outcome lands in the hook's `onPurchaseSuccess` / `onPurchaseError` callbacks. - * @throws Synchronous rejection from the store (e.g. `E_NOT_PREPARED`, validation failure). + * @throws Synchronous rejection from the store (e.g. `ErrorCode.NotPrepared`, validation failure). * * @example * ```ts @@ -553,9 +547,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { * }); * ``` * - * @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} / - * {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`. - * * @see {@link https://openiap.dev/docs/apis/request-purchase} */ const requestPurchaseWithReset = useCallback( @@ -623,7 +614,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { }, []); /** - * Verify via a managed provider — currently only `iapkit` (IAPKit). The PurchaseVerificationProvider enum exposes no other provider literal today. + * Verify via a managed provider; `iapkit` (IAPKit) is the only one. * * @see {@link https://openiap.dev/docs/features/validation#verify-purchase-with-provider} */ @@ -634,7 +625,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { [], ); - // Build the canonical billing-program connection config. const buildConnectionConfig = useCallback((): | InitConnectionConfig | undefined => { @@ -664,18 +654,14 @@ export function useIAP(options?: UseIAPOptions): UseIap { } startedInitializationGenerationRef.current = generation; - // CRITICAL: Register listeners BEFORE initConnection to avoid race condition - // Events might fire immediately after initConnection, so listeners must be ready - // Register purchase update listener BEFORE initConnection to avoid race conditions. + // Register listeners before initConnection; events can fire as soon as it connects. subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener( async (purchase: Purchase) => { if (!markPurchaseDelivered(purchase)) { return; } - // Refresh subscription status for both iOS and Android subscription purchases. - // refreshSubscriptionStatus internally checks whether the product is a known - // subscription, so it is safe to call unconditionally for any purchase event. + // Safe for any purchase: it skips products that are not known subscriptions. await refreshSubscriptionStatus(purchase.productId); if (optionsRef.current?.onPurchaseSuccess) { @@ -685,7 +671,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { optionsRef.current?.purchaseUpdatedListenerOptions, ); - // Register purchase error listener EARLY. Ignore init-related errors until connected. subscriptionsRef.current.purchaseError = purchaseErrorListener( (error: PurchaseError) => { if ( @@ -736,7 +721,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { }); if (Platform.OS === 'ios') { - // iOS promoted products listener subscriptionsRef.current.promotedProductIOS = promotedProductListenerIOS((product: Product) => { setPromotedProductIOS(product); @@ -747,7 +731,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { }); } - // NOW call initConnection after listeners are ready const config = buildConnectionConfig(); try { @@ -764,7 +747,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { } setConnected(result); if (!result) { - // If connection failed, clean up listeners ExpoIapConsole.warn( '[useIAP] Connection failed, cleaning up listeners...', ); @@ -783,7 +765,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { } ExpoIapConsole.error('initConnection failed:', error); invokeOnError(error); - // Clean up listeners on error subscriptionsRef.current.purchaseUpdate?.remove(); subscriptionsRef.current.promotedProductIOS?.remove(); subscriptionsRef.current.purchaseUpdate = undefined; @@ -798,9 +779,6 @@ export function useIAP(options?: UseIAPOptions): UseIap { ], ); - // Manual reconnect method for when the initial auto-connect fails. - // Re-runs initConnection and updates the connected state. - // Re-registers event listeners if they were cleaned up during a previous failure. const reconnect = useCallback(async (): Promise => { const config = buildConnectionConfig(); @@ -891,11 +869,9 @@ export function useIAP(options?: UseIAPOptions): UseIap { verifyPurchase, verifyPurchaseWithProvider, restorePurchases: restorePurchasesInternal, - // internal getters kept for hook state management getPromotedProductIOS, getActiveSubscriptions: getActiveSubscriptionsInternal, hasActiveSubscriptions: hasActiveSubscriptionsInternal, - // Reconnect method for manual retry reconnect, getBillingChoiceInfoAndroid, isBillingProgramAvailableAndroid, diff --git a/libraries/expo-iap/src/utils/__tests__/errorMapping.test.ts b/libraries/expo-iap/src/utils/__tests__/errorMapping.test.ts index 36f2d229f..1f63e357a 100644 --- a/libraries/expo-iap/src/utils/__tests__/errorMapping.test.ts +++ b/libraries/expo-iap/src/utils/__tests__/errorMapping.test.ts @@ -52,7 +52,7 @@ describe('errorMapping utils', () => { expect(getUserFriendlyErrorMessage({code: ErrorCode.EmptySkuList})).toMatch( /No product IDs/i, ); - expect(getUserFriendlyErrorMessage({code: 'UNKNOWN'} as any)).toBe( + expect(getUserFriendlyErrorMessage({code: 'UNKNOWN'})).toBe( 'An unexpected error occurred', ); }); diff --git a/libraries/expo-iap/src/utils/__tests__/tsconfig.json b/libraries/expo-iap/src/utils/__tests__/tsconfig.json index 215877550..f83e3b2ba 100644 --- a/libraries/expo-iap/src/utils/__tests__/tsconfig.json +++ b/libraries/expo-iap/src/utils/__tests__/tsconfig.json @@ -1,8 +1,6 @@ -// Editor-only project for the Jest specs in this directory. The package -// tsconfig excludes __tests__ so expo-module-scripts never emits test files -// into build/, but that leaves VS Code without a project for them and every -// jest global errors with ts(2708). This config re-attaches the directory to -// the package compiler options; ts-jest still type-checks specs at test time. +// Editor-only: gives VS Code a project for these specs, which the package +// tsconfig excludes so expo-module-scripts keeps them out of build/. Without it, +// every Jest global errors with ts(2708). ts-jest type-checks specs at test time. { "extends": "../../../tsconfig.json", "compilerOptions": { diff --git a/libraries/expo-iap/src/utils/debug.ts b/libraries/expo-iap/src/utils/debug.ts index 3f0befbcf..991dea9ff 100644 --- a/libraries/expo-iap/src/utils/debug.ts +++ b/libraries/expo-iap/src/utils/debug.ts @@ -1,15 +1,10 @@ /** - * Debug logger for Expo IAP - * Only logs when explicitly enabled for library development - * Silent for all library users (even in their dev mode) + * Debug logger for Expo IAP. log/debug/info print only when EXPO_IAP_DEV_MODE + * is set for library development, so apps stay silent even in dev mode; + * warn and error always print. */ -// Check if we're in library development mode -// This will be false for library users, even in their dev environment const isLibraryDevelopment = () => { - // Only show logs if explicitly enabled via environment variable - // Library developers can set: EXPO_IAP_DEV_MODE=true - // Read both through a typed globalThis: a consumer type-checking this file // has no Node types, so a bare `process` does not compile. const g = globalThis as { @@ -27,23 +22,19 @@ const createConsole = () => ({ if (isLibraryDevelopment()) { console.log('[Expo-IAP]', ...args); } - // Silent for library users }, debug: (...args: any[]) => { if (isLibraryDevelopment()) { console.debug('[Expo-IAP Debug]', ...args); } - // Silent for library users }, warn: (...args: any[]) => { - // Warnings are always shown console.warn('[Expo-IAP]', ...args); }, error: (...args: any[]) => { - // Errors are always shown console.error('[Expo-IAP]', ...args); }, @@ -51,9 +42,7 @@ const createConsole = () => ({ if (isLibraryDevelopment()) { console.info('[Expo-IAP]', ...args); } - // Silent for library users }, }); -// Export a singleton instance export const ExpoIapConsole = createConsole(); diff --git a/libraries/expo-iap/src/utils/errorMapping.ts b/libraries/expo-iap/src/utils/errorMapping.ts index 3d6820d7a..2a4f53990 100644 --- a/libraries/expo-iap/src/utils/errorMapping.ts +++ b/libraries/expo-iap/src/utils/errorMapping.ts @@ -1,8 +1,4 @@ -/** - * Error mapping utilities for expo-iap. - * Provides helpers for working with platform-specific error codes - * and constructing structured purchase errors. - */ +/** Helpers for platform error codes and structured purchase errors. */ import { ErrorCode, @@ -271,13 +267,9 @@ export const createPurchaseErrorFromPlatform = ( }; /** - * Rebuild a canonical PurchaseError from an Expo Modules Promise rejection. - * - * Native Expo async functions cannot attach arbitrary fields to the rejected - * JavaScript Error. The iOS and Android bridges therefore place the complete - * payload in a marked JSON envelope inside the rejection message. This helper - * also accepts direct fields so the Vega/Onside adapters and older native - * builds continue to work. + * Rebuild a canonical PurchaseError from an Expo Modules Promise rejection, + * read from the message envelope (see OPENIAP_ERROR_ENVELOPE_PREFIX). Direct + * fields are accepted too, for the Vega/Onside adapters and older native builds. */ export const createPurchaseErrorFromNativeException = ( error: unknown, diff --git a/libraries/expo-iap/src/utils/restorePurchases.ts b/libraries/expo-iap/src/utils/restorePurchases.ts index 22b826187..10d7658d6 100644 --- a/libraries/expo-iap/src/utils/restorePurchases.ts +++ b/libraries/expo-iap/src/utils/restorePurchases.ts @@ -11,12 +11,11 @@ import {createPurchaseError} from './errorMapping'; * a subsequent empty purchase query. */ export const restorePurchasesIOSNative = async (): Promise => { - const nativeModule = ExpoIapModule as any; const usingOnside = - nativeModule.USING_ONSIDE_SDK && - typeof nativeModule.restorePurchases === 'function'; + ExpoIapModule.USING_ONSIDE_SDK && + typeof ExpoIapModule.restorePurchases === 'function'; const restored = usingOnside - ? await nativeModule.restorePurchases() + ? await ExpoIapModule.restorePurchases?.() : await syncIOS(); if (restored !== true) { diff --git a/libraries/flutter_inapp_purchase/README.md b/libraries/flutter_inapp_purchase/README.md index 4e8d3c41d..949edbbd9 100644 --- a/libraries/flutter_inapp_purchase/README.md +++ b/libraries/flutter_inapp_purchase/README.md @@ -49,7 +49,7 @@ Apps that use this package only on Apple platforms can exclude every Android store SDK. Add this to the app's `android/gradle.properties`: ```properties -openiapPlatform=none +openiapStore=none ``` Then run `flutter clean` before the next Android build. @@ -58,8 +58,11 @@ The Android plugin remains registered, but it compiles a no-op implementation: `initConnection()` returns `false`, and store operations report `ErrorCode.IapNotAvailable`. The build contains no OpenIAP Google, Play Billing, Horizon, or Amazon IAP SDK dependency, and no billing manifest entry supplied by -those SDKs. Omitting the property keeps Google Play as the default. Do not -combine the property with `horizonEnabled` or `fireOsEnabled`. +those SDKs. Without the property the build resolves the store itself: a +`horizon` or `amazon` flavor, a connected Quest or Fire device on debug builds, +or an `openiapStore=horizon|amazon` pin; Google Play otherwise. The legacy +`openiapPlatform=none`, `horizonEnabled`, and `fireOsEnabled` properties still +work with a deprecation warning. ## 🔧 Quick Start diff --git a/libraries/flutter_inapp_purchase/android/build.gradle b/libraries/flutter_inapp_purchase/android/build.gradle index 342f1639d..7dfa53aad 100644 --- a/libraries/flutter_inapp_purchase/android/build.gradle +++ b/libraries/flutter_inapp_purchase/android/build.gradle @@ -138,25 +138,10 @@ apply from: project.file('openiap-android-sdk.gradle') def openIapCompileSdkVersion = openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', 36) def openIapMinSdkVersion = openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', 23) def openIapTargetSdkVersion = openIapResolveAndroidSdkVersion('targetSdkVersion', 'compileSdk', 36) -def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false -def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false -if (horizonEnabled && fireOsEnabled) { - throw new GradleException("flutter_inapp_purchase: horizonEnabled and fireOsEnabled cannot both be true") -} - -def legacyOpenIapPlatform = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') -def requestedOpenIapPlatform = project.findProperty('openiapPlatform')?.toString()?.trim()?.toLowerCase(Locale.ROOT) -if (requestedOpenIapPlatform != null && requestedOpenIapPlatform != 'none') { - throw new GradleException( - "flutter_inapp_purchase: openiapPlatform only supports the opt-out value 'none'" - ) -} -if (requestedOpenIapPlatform == 'none' && (horizonEnabled || fireOsEnabled)) { - throw new GradleException( - "flutter_inapp_purchase: openiapPlatform=none conflicts with legacy store flags" - ) -} -def openiapPlatform = requestedOpenIapPlatform ?: legacyOpenIapPlatform +// Store selection: explicit property > task flavor > connected debug device > play. +// openiapStore=none (legacy openiapPlatform=none) builds without any Android IAP SDK. +apply from: project.file('openiap-store.gradle') +def openiapPlatform = openIapResolveStore('flutter_inapp_purchase', [allowNone: true]).store def androidIapDisabled = openiapPlatform == 'none' android { diff --git a/libraries/flutter_inapp_purchase/android/openiap-store.gradle b/libraries/flutter_inapp_purchase/android/openiap-store.gradle new file mode 120000 index 000000000..6ae291887 --- /dev/null +++ b/libraries/flutter_inapp_purchase/android/openiap-store.gradle @@ -0,0 +1 @@ +../../../packages/google/gradle/openiap-store.gradle \ No newline at end of file diff --git a/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index e2380deb7..560030786 100644 --- a/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -1054,6 +1054,17 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act iapkitMap["amazon"] = amazonMap } } + (iapkit["horizon"] as? Map<*, *>)?.let { horizon -> + (horizon["sku"] as? String)?.let { sku -> + val horizonMap = mutableMapOf( + "sku" to sku + ) + (horizon["userId"] as? String)?.let { userId -> + horizonMap["userId"] = userId + } + iapkitMap["horizon"] = horizonMap + } + } propsMap["iapkit"] = iapkitMap } diff --git a/libraries/flutter_inapp_purchase/example/README.md b/libraries/flutter_inapp_purchase/example/README.md index e2211832d..63b423633 100644 --- a/libraries/flutter_inapp_purchase/example/README.md +++ b/libraries/flutter_inapp_purchase/example/README.md @@ -72,70 +72,64 @@ flutter run flutter build apk --release ``` -### Meta Horizon (Meta Quest) +### Store selection -To use Meta Horizon billing: +The build picks the Android store; nothing in the project changes between +stores. First match wins: -1. **Enable Horizon** in `android/gradle.properties`: +1. `-PopeniapStore=horizon|amazon|play` (or the same key in + `android/gradle.properties`) pins it. +2. A flavor names it, in an app that declares those flavors: + `flutter build apk --flavor horizon`. This example declares none, so pin + the store or let the device pick it. +3. On debug builds, the connected Quest or Fire device names it — the one + `ANDROID_SERIAL` selects, or the only one attached. +4. Google Play otherwise. - ```properties - horizonEnabled=true - ``` +Gradle prints `openiap: store=... (source=...)` once per build. + +### Meta Horizon (Meta Quest) -2. **Add Horizon App ID** to `android/local.properties`: +1. **Add the Horizon App ID** to `android/local.properties`; it is inert on + other stores, so it stays there permanently: ```properties HORIZON_APP_ID=your_horizon_app_id_here ``` -3. **Run on Quest**: +2. **Run on Quest** with the headset as the only connected device, or pin the + store for a release build through the Gradle property: + ```bash - flutter run -d Quest - flutter build apk --release + flutter run + ORG_GRADLE_PROJECT_openiapStore=horizon flutter build apk --release ``` -**No flavor specification needed!** The build system automatically selects the correct billing platform based on `horizonEnabled` or `fireOsEnabled`. - ### Fire OS -To use Fire OS IAP through the Amazon Appstore SDK: +1. **Add the Amazon public key** `AppstoreAuthenticationKey.pem` to + `android/app/src/main/assets/` (download it from the Amazon Developer + Console); it is inert on other stores. -1. **Enable Fire OS** in `android/gradle.properties`: - - ```properties - fireOsEnabled=true - ``` - -2. **Keep Horizon disabled** in the same build: - - ```properties - horizonEnabled=false - ``` - -3. **Test with Amazon App Tester** on a Fire OS or compatible Android test device: +2. **Test with Amazon App Tester** on a Fire device as the only connected + device, or pin the store for the release build: ```bash flutter run - flutter build apk --release + ORG_GRADLE_PROJECT_openiapStore=amazon flutter build apk --release ``` -The build system automatically selects the Fire OS `amazon` flavor based on -`fireOsEnabled`. - ### No Android IAP To keep the Flutter package for iOS or macOS while excluding Android store SDKs, set this in `android/gradle.properties`: ```properties -openiapPlatform=none +openiapStore=none ``` -Run `flutter clean` before rebuilding after changing this property. - -`openiapPlatform=none` cannot be combined with `horizonEnabled` or -`fireOsEnabled`; disable both legacy store flags first, or the Android build -fails with `openiapPlatform=none conflicts with legacy store flags`. +Run `flutter clean` before rebuilding after changing this property. The legacy +`openiapPlatform=none` spelling still works with a deprecation warning. `initConnection()` then returns `false`, and Android store operations report `ErrorCode.IapNotAvailable`. The APK contains no Play Billing, Horizon, or @@ -146,7 +140,8 @@ SDKs. ### Android Studio -Just click **Run** - the build system automatically selects the right platform based on `horizonEnabled` or `fireOsEnabled` in `gradle.properties`. +Just click **Run** — on a debug build the connected Quest or Fire device +selects the store, and `openiapStore` in `gradle.properties` overrides it. ### VS Code @@ -154,6 +149,11 @@ Press F5 or click **Start Debugging** - works out of the box! ## Testing -- **Google Play**: Test on any Android device with Google Play Store (default) -- **Meta Horizon**: Set `horizonEnabled=true` and test on Meta Quest devices -- **Fire OS**: Set `fireOsEnabled=true` and test with Amazon App Tester +- **Google Play**: test on any Android device with the Play Store (default) +- **Meta Horizon**: plug in a Quest, or set `openiapStore=horizon` +- **Fire OS**: plug in a Fire device, or set `openiapStore=amazon`, and test + with Amazon App Tester + +`horizonEnabled` and `fireOsEnabled` are deprecated but still read, with a +warning, so a stale one still selects a store and fails the build if it +disagrees with `openiapStore`. Delete them rather than leaving them set. diff --git a/libraries/flutter_inapp_purchase/example/android/app/build.gradle b/libraries/flutter_inapp_purchase/example/android/app/build.gradle index 37a094423..75b091f2d 100644 --- a/libraries/flutter_inapp_purchase/example/android/app/build.gradle +++ b/libraries/flutter_inapp_purchase/example/android/app/build.gradle @@ -17,6 +17,8 @@ if (!usesBuiltInKotlin) { } apply from: rootProject.file('../../android/openiap-android-sdk.gradle') +// Same resolver as flutter_inapp_purchase, so the app and the plugin link one store. +apply from: new File(project(':flutter_inapp_purchase').projectDir, 'openiap-store.gradle') def openIapCompileSdkVersion = openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', 36) def openIapResolvedMinSdkVersion = (openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', 23) ?: 23) as Integer @@ -25,14 +27,9 @@ def openIapJunitVersion = openIapResolveDependencyVersion('junit:junit', 'openIa def openIapAndroidTestRunnerVersion = openIapResolveDependencyVersion('androidx.test:runner', 'openIapAndroidTestRunnerVersion') def openIapEspressoCoreVersion = openIapResolveDependencyVersion('androidx.test.espresso:espresso-core', 'openIapEspressoCoreVersion') -// Read store flags from gradle.properties (default: Play) -def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false -def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false -if (horizonEnabled && fireOsEnabled) { - throw new GradleException("flutter_inapp_purchase example: horizonEnabled and fireOsEnabled cannot both be true") -} -def openIapMinSdkVersion = fireOsEnabled ? Math.max(openIapResolvedMinSdkVersion, 24) : openIapResolvedMinSdkVersion -def openIapFlavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') +// Same resolver as the plugin, so the app and flutter_inapp_purchase link one store. +def openIapFlavor = openIapResolveStore('app', [allowNone: true]).store +def openIapMinSdkVersion = openIapFlavor == 'amazon' ? Math.max(openIapResolvedMinSdkVersion, 24) : openIapResolvedMinSdkVersion def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') @@ -75,14 +72,14 @@ android { versionName = flutterVersionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - // Select platform flavor from plugin's product flavors - missingDimensionStrategy 'platform', openIapFlavor + // Select platform flavor from plugin's product flavors; none links no store SDK. + missingDimensionStrategy 'platform', openIapFlavor == 'none' ? 'play' : openIapFlavor // Configure Horizon App ID if enabled // Ships the shared example app id like the other OpenIAP examples; an // empty value would only surface as a startConnection crash on device. def horizonAppId = "" - if (horizonEnabled) { + if (openIapFlavor == 'horizon') { // Blank counts as absent: getProperty returns "" for a key present // with no value, and "" is non-null, so elvis alone would keep it. horizonAppId = localProperties.getProperty("HORIZON_APP_ID")?.trim() ?: "31705015229097839" diff --git a/libraries/flutter_inapp_purchase/example/android/gradle.properties b/libraries/flutter_inapp_purchase/example/android/gradle.properties index 1adce2d27..068ce706a 100644 --- a/libraries/flutter_inapp_purchase/example/android/gradle.properties +++ b/libraries/flutter_inapp_purchase/example/android/gradle.properties @@ -7,19 +7,15 @@ openIapJunitVersion=4.13.2 openIapAndroidTestRunnerVersion=1.7.0 openIapEspressoCoreVersion=3.7.0 -# Enable Horizon OS support (Meta Quest) -# Default: false (uses Google Play Billing) -# Uncomment to enable Horizon billing: -# horizonEnabled=true - -# Enable Fire OS support for Amazon distribution -# Default: false (uses Google Play Billing unless horizonEnabled=true) -# Do not enable with horizonEnabled in the same build. -# fireOsEnabled=true +# Store selection is automatic: a flavor (flutter run --flavor horizon, in an +# app that declares them — this example declares none) or, for +# debug builds, the connected Quest or Fire device (ANDROID_SERIAL picks among +# several) selects the store; play otherwise. Pin one here or with +# -PopeniapStore= when a build must not look at a device. +# openiapStore=horizon # Exclude every Android store SDK while keeping Apple IAP support. -# Do not combine this with horizonEnabled or fireOsEnabled. -# openiapPlatform=none +# openiapStore=none # This builtInKotlin flag was added automatically by Flutter migrator android.builtInKotlin=false # This newDsl flag was added automatically by Flutter migrator diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart index 1c55eceef..66caf0349 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart @@ -118,11 +118,11 @@ Transaction ID: ${purchase.id} '''; }); - // Finish transaction + // Demo: finishes without verification; only bulb packs are consumed. try { await FlutterInappPurchase.instance.finishTransaction( purchase: purchase, - isConsumable: true, + isConsumable: IapConstants.isConsumable(purchase.productId), ); } catch (e) { debugPrint('[AlternativeBilling] Failed to finish transaction: $e'); diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/builder_demo_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/builder_demo_screen.dart index 7c69d482c..e681f2486 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/builder_demo_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/builder_demo_screen.dart @@ -76,8 +76,9 @@ class _BuilderDemoScreenState extends State { _isProcessing = false; }); - // Finish transaction - final bool isConsumable = !purchase.isAutoRenewing; + // Builder demo: finishes without verification (Purchase Flow shows the + // verified path). The badge does not renew but is not consumable. + final bool isConsumable = IapConstants.isConsumable(purchase.productId); _iap .finishTransaction(purchase: purchase, isConsumable: isConsumable) .then((_) { diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/debug_purchases_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/debug_purchases_screen.dart index 9631ef04f..9e4a202a5 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/debug_purchases_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/debug_purchases_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; import 'package:flutter_inapp_purchase/types.dart' as gentype; +import '../constants.dart'; import '../widgets/purchase_detail_view.dart'; class DebugPurchasesScreen extends StatefulWidget { @@ -63,16 +64,12 @@ class _DebugPurchasesScreenState extends State { productId.contains('pro'); } - bool _isConsumable(String? productId) { - if (productId == null) return false; - // Check if product ID contains consumable keywords - return productId.contains('bulbs') || - productId.contains('coins') || - productId.contains('gems') || - productId.contains('lives') || - productId.contains('consumable'); - } + // Gates the Consume button, so it must follow the catalog: consuming the + // badge would drop its entitlement. + bool _isConsumable(String? productId) => + productId != null && IapConstants.isConsumable(productId); + // Debug tool: consumes without verification to reset sandbox test state. Future _consumePurchase(gentype.Purchase purchase) async { if (purchase.purchaseToken == null) { _showAlert('Error', 'No purchase token available'); diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/purchase_flow_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/purchase_flow_screen.dart index 21cda1797..6c7f2d30e 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/purchase_flow_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/purchase_flow_screen.dart @@ -266,6 +266,14 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt debugPrint('ID: ${purchase.id}'); // OpenIAP standard debugPrint('Transaction ID: ${transactionId ?? 'N/A'}'); + // A redelivery can land while this attempt is still verifying, so claim + // the id now and release it only if this attempt does not finish. + if (transactionId != null && + !_processedTransactionIds.add(transactionId)) { + debugPrint('⚠️ Transaction already in progress: $transactionId'); + return; + } + if (!mounted) return; setState(() { _isProcessing = false; @@ -289,22 +297,20 @@ Purchase credential: ${purchase.purchaseToken?.isNotEmpty == true ? 'Present' : if (!verificationOk) { debugPrint( '⚠️ Skipping finishTransaction because IAPKit verification did not return isValid=true'); + _processedTransactionIds.remove(transactionId); return; } } else if (_verificationMethod == VerificationMethod.local) { await _verifyPurchaseLocally(purchase); } - // After verification, finish the transaction - // For consumable products (like bulb packs), set isConsumable to true + // Consuming the badge would drop its entitlement on Android, so only + // bulb packs are consumed. try { await _iap.finishTransaction( purchase: purchase, - isConsumable: true, + isConsumable: IapConstants.isConsumable(purchase.productId), ); - if (transactionId != null) { - _processedTransactionIds.add(transactionId); - } debugPrint('Transaction finished successfully'); if (!mounted) return; setState(() { @@ -313,6 +319,7 @@ Purchase credential: ${purchase.purchaseToken?.isNotEmpty == true ? 'Present' : }); } catch (e) { debugPrint('Error finishing transaction: $e'); + _processedTransactionIds.remove(transactionId); if (!mounted) return; setState(() { _purchaseResult = diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart index 2918248ba..c4625d134 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart @@ -194,6 +194,14 @@ class _SubscriptionFlowScreenState extends State { } if (isPurchased) { + // Claim the key before verifying, since a redelivery can land + // mid-flight; a failed attempt releases it below. + if (transactionKey.isNotEmpty && + !_processedTransactionIds.add(transactionKey)) { + debugPrint(' ⚠️ Transaction already in progress'); + return; + } + debugPrint('✅ Purchase detected as successful, updating UI...'); debugPrint(' _isProcessing before setState: $_isProcessing'); @@ -224,9 +232,9 @@ class _SubscriptionFlowScreenState extends State { if (!verificationOk) { debugPrint( '⚠️ Skipping finishTransaction because IAPKit verification did not return isValid=true'); - // Leave the transaction unfinished so the platform retries on the - // next foreground (and don't mark `transactionKey` processed — - // the next listener emit gets a fresh chance). + // Leave the transaction unfinished so the platform retries, and + // release the key so the next listener emit gets a fresh chance. + _processedTransactionIds.remove(transactionKey); return; } @@ -243,12 +251,10 @@ class _SubscriptionFlowScreenState extends State { debugPrint('Error finishing transaction: $e'); } - // Only mark this transactionKey processed once verification AND - // finishTransaction have both succeeded; otherwise a transient - // failure would permanently short-circuit retries for the rest - // of the session. - if (finishedOk && transactionKey.isNotEmpty) { - _processedTransactionIds.add(transactionKey); + // A transient finish failure must not short-circuit retries for + // the rest of the session. + if (!finishedOk) { + _processedTransactionIds.remove(transactionKey); } // Refresh subscriptions after a short delay to ensure transaction is processed diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index 057a7c7d1..812afb31d 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -329,15 +329,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) case "isEligibleForExternalPurchaseCustomLinkIOS": - if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { isEligibleForExternalPurchaseCustomLinkIOS(result: result) } else { let code: ErrorCode = .featureNotSupported - result(FlutterError(code: code.rawValue, message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.0+, or tvOS 18.1+", details: nil)) + result(FlutterError(code: code.rawValue, message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.1+, or tvOS 18.1+", details: nil)) } case "getExternalPurchaseCustomLinkTokenIOS": - if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { if let args = call.arguments as? [String: Any], let tokenType = args["tokenType"] as? String { getExternalPurchaseCustomLinkTokenIOS(tokenType: tokenType, result: result) @@ -349,11 +349,11 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } else { let code: ErrorCode = .featureNotSupported - result(FlutterError(code: code.rawValue, message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.0+, or tvOS 18.1+", details: nil)) + result(FlutterError(code: code.rawValue, message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.1+, or tvOS 18.1+", details: nil)) } case "showExternalPurchaseCustomLinkNoticeIOS": - if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { if let args = call.arguments as? [String: Any], let noticeType = args["noticeType"] as? String { showExternalPurchaseCustomLinkNoticeIOS(noticeType: noticeType, result: result) @@ -365,7 +365,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } else { let code: ErrorCode = .featureNotSupported - result(FlutterError(code: code.rawValue, message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.0+, or tvOS 18.1+", details: nil)) + result(FlutterError(code: code.rawValue, message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.1+, or tvOS 18.1+", details: nil)) } case "verifyPurchaseWithProvider": @@ -1241,7 +1241,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) - @available(iOS 18.1, macOS 15.0, tvOS 18.1, *) + @available(iOS 18.1, macOS 15.1, tvOS 18.1, *) private func isEligibleForExternalPurchaseCustomLinkIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("isEligibleForExternalPurchaseCustomLinkIOS called") Task { @MainActor in @@ -1261,7 +1261,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - @available(iOS 18.1, macOS 15.0, tvOS 18.1, *) + @available(iOS 18.1, macOS 15.1, tvOS 18.1, *) private func getExternalPurchaseCustomLinkTokenIOS(tokenType: String, result: @escaping FlutterResult) { FlutterIapLog.payload("getExternalPurchaseCustomLinkTokenIOS", payload: ["tokenType": tokenType]) Task { @MainActor in @@ -1287,7 +1287,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - @available(iOS 18.1, macOS 15.0, tvOS 18.1, *) + @available(iOS 18.1, macOS 15.1, tvOS 18.1, *) private func showExternalPurchaseCustomLinkNoticeIOS(noticeType: String, result: @escaping FlutterResult) { FlutterIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", payload: ["noticeType": noticeType]) Task { @MainActor in diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 9165ab8a4..1dfe512f5 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -120,12 +120,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// Purchase updated event stream with listener options. /// - /// On iOS, set [PurchaseUpdatedListenerOptions.dedupeTransactionIOS] - /// to false to also receive StoreKit replay events for transaction IDs - /// already delivered during the current connection session. Android ignores - /// this flag. On iOS this configures shared native listener state for this - /// plugin instance; default streams still filter replayed IDs unless they - /// opt out with `dedupeTransactionIOS: false`. + /// On iOS, set [PurchaseUpdatedListenerOptions.dedupeTransactionIOS] to + /// false to also receive StoreKit replays of transaction IDs already + /// delivered this connection session. Native listener state is shared per + /// plugin instance, but streams that do not opt out still filter replays. + /// Android ignores the flag. Stream purchaseUpdatedListenerWithOptions( gentype.PurchaseUpdatedListenerOptions? options, ) { @@ -278,9 +277,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// Subscription billing-issue event stream (cross-platform). /// /// Emits when an active subscription needs user attention for a payment - /// problem. Unifies StoreKit 2 `Message.Reason.billingIssue` (iOS / Mac Catalyst 16.4+, visionOS 1.0+) and - /// Google Play Billing `Purchase.isSuspended` (Play Billing 8.1+). NOT - /// emitted on the Meta Horizon flavor (Billing 7.0 compat lacks the signal). + /// problem: StoreKit 2 `Message.Reason.billingIssue` (iOS / Mac Catalyst + /// 16.4+, visionOS 1.0+) or Play `Purchase.isSuspended` (Billing 8.1+). + /// Not emitted on Meta Horizon (its Billing 7.0 compat lacks the signal). Stream get subscriptionBillingIssueListener => _subscriptionBillingIssueListener.stream; @@ -444,7 +443,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { try { await _configurePurchaseListener(_setPurchaseListener); - // Build config map for the selected billing program. Map? config; if (billingChoiceScreenTypeAndroid != null || enableBillingProgramAndroid != null) { @@ -487,7 +485,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } try { - // For flutter IAP compatibility, call endConnection directly await _channel.invokeMethod('endConnection'); _isInitialized = false; @@ -556,7 +553,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { try { if (_platform.isIOS || _platform.isMacOS) { - // Extract props from the JSON representation final json = params.toJson(); final requestKey = type == 'in-app' ? 'requestPurchase' : 'requestSubscription'; @@ -598,7 +594,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } if (_platform.isAndroid) { - // Extract props from the JSON representation final json = params.toJson(); final requestKey = type == 'in-app' ? 'requestPurchase' : 'requestSubscription'; @@ -612,12 +607,10 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } - // Parse Android props based on type final androidProps = productType == gentype.ProductQueryType.InApp ? gentype.RequestPurchaseAndroidProps.fromJson(androidData) : gentype.RequestSubscriptionAndroidProps.fromJson(androidData); - // Handle both RequestPurchaseAndroidProps and RequestSubscriptionAndroidProps final List skus; final bool? isOfferPersonalized; final String? obfuscatedAccount; @@ -1704,9 +1697,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } if (consumable) { - debugPrint( - '[FlutterInappPurchase] Android: Consuming product with token: ${purchase.purchaseToken}', - ); + if (kDebugMode) { + debugPrint( + '[FlutterInappPurchase] Android: Consuming ${purchase.productId}', + ); + } final result = await _channel.invokeMethod( 'consumePurchaseAndroid', {'purchaseToken': purchase.purchaseToken}, @@ -1736,7 +1731,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { if (kDebugMode) { debugPrint( - '[FlutterInappPurchase] Android: Acknowledging purchase with token: ${purchase.purchaseToken}', + '[FlutterInappPurchase] Android: Acknowledging ${purchase.productId}', ); } @@ -1804,7 +1799,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { _acknowledgedAndroidPurchaseTokens[purchase.purchaseToken!] = true; } else if (kDebugMode) { debugPrint( - '[FlutterInappPurchase] Android: Acknowledge response indicated failure; will retry later (${purchase.purchaseToken})', + '[FlutterInappPurchase] Android: Acknowledge response indicated failure; will retry later (${purchase.productId})', ); } return; @@ -1829,7 +1824,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); }; - /// Verify via a managed provider (currently IAPKit; the PurchaseVerificationProvider enum exposes only Iapkit today). + /// Verify via a managed provider (only IAPKit today). /// /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider gentype.MutationVerifyPurchaseWithProviderHandler @@ -1885,7 +1880,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } - // Parse result (can be Map or String) final Map resultMap; if (result is String) { resultMap = jsonDecode(result) as Map; @@ -1900,7 +1894,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } - // Parse iapkit result (single object, not array) gentype.RequestVerifyPurchaseWithIapkitResult parseIapkitResult( dynamic value) { if (value is! Map) { @@ -2022,7 +2015,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } - // Parse errors if present final errorsData = resultMap['errors'] as List?; final errors = errorsData?.map((e) { final errorMap = e is Map @@ -2211,15 +2203,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { '[flutter_inapp_purchase] Processed ${products.length} products', ); - // Return list directly based on query type if (queryType == gentype.ProductQueryType.All) { - // For 'All' type, return all products debugPrint( '[flutter_inapp_purchase] Type All: returning ${products.length} total products', ); return products; } else if (queryType == gentype.ProductQueryType.Subs) { - // For subscription queries, return only subscriptions final subscriptions = products .whereType() .toList(growable: false); @@ -2228,7 +2217,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); return subscriptions; } else { - // Default to in-app products final inApps = products.whereType().toList( growable: false, ); @@ -2300,7 +2288,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } } - // Fetch available purchases using the public API await getAvailablePurchases(); } catch (error) { if (error is PurchaseError) rethrow; @@ -2359,9 +2346,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { try { decoded = json.decode(result); } on FormatException catch (error) { - // Malformed JSON and a non-list payload are the same class of failure, - // so report one code instead of letting FormatException fall through - // to the generic catch and surface as ServiceError. + // Report malformed JSON like a non-list payload, not as ServiceError. throw PurchaseError( code: gentype.ErrorCode.BillingResponseJsonParseError, message: 'Failed to decode native active-subscription response: ' @@ -2434,10 +2419,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } try { - // Use native getActiveSubscriptions for both iOS and Android - // This ensures we get complete ActiveSubscription objects including: - // - renewalInfoIOS on iOS (with upgrade/downgrade/cancellation status) - // - autoRenewingAndroid on Android + // Native on both platforms, so results carry renewalInfoIOS + // (upgrade/downgrade/cancellation status) and autoRenewingAndroid. final result = await _channel.invokeMethod( 'getActiveSubscriptions', subscriptionIds, @@ -2731,13 +2714,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } }; - /// Open the Google Play offer/promo code redemption flow so the user can - /// enter a code. + /// Open the Play Store offer/promo code redeem page. /// - /// On Play builds, launches the Play Store redeem page. A listener can - /// receive the purchase while the app has an active billing connection; - /// reconcile available purchases when the app resumes. Unsupported store - /// flavors return false. Android counterpart of `presentCodeRedemptionSheetIOS`. + /// The purchase can reach a listener while billing is connected; reconcile + /// available purchases when the app resumes. Other store flavors return + /// false. Android counterpart of `presentCodeRedemptionSheetIOS`. /// /// See: https://openiap.dev/docs/apis/android/open-redeem-offer-code-android @Deprecated( @@ -2959,9 +2940,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { queryType: queryType, ); - // Wrap list in the generated result union for OpenIAP compatibility. - // `All` must preserve product and subscription variants instead of - // flattening the mixed result into the product-only branch. + // `All` keeps each item's product or subscription variant instead of + // flattening the mix into the product-only branch. if (queryType == gentype.ProductQueryType.All) { final wrapped = products .map((product) { diff --git a/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh b/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh index e8f6f8a41..d56503cb2 100755 --- a/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh +++ b/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh @@ -33,9 +33,10 @@ rm -rf \ "$local_google_copy/.gradle" \ "$local_google_copy/build" -rm -f "$package_copy/openiap-versions.json" +rm -f "$package_copy/openiap-versions.json" "$package_copy/android/openiap-store.gradle" cp "$repo_root/openiap-versions.json" "$package_copy/openiap-versions.json" cp "$repo_root/openiap-versions.json" "$tmp_root/openiap-versions.json" +cp "$repo_root/packages/google/gradle/openiap-store.gradle" "$package_copy/android/openiap-store.gradle" flutter create --platforms=android -t app --project-name openiap_consumer_smoke "$consumer_app" @@ -131,7 +132,7 @@ fi flutter build apk --debug - printf '\nopeniapPlatform=none\n' >> android/gradle.properties + printf '\nopeniapStore=none\n' >> android/gradle.properties flutter clean flutter pub get flutter build apk --debug diff --git a/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart b/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart index 83d3868c6..38c6ca862 100644 --- a/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart +++ b/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart @@ -357,6 +357,29 @@ void main() { await iap.finishTransaction(purchase: _androidPurchase('failure')); }); + test('Android finish logs never include the purchase token', () async { + const token = 'secret-purchase-token'; + final logs = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + await iap.finishTransaction( + purchase: _androidPurchase(token), + isConsumable: true, + ); + // A null acknowledge response also logs the retry line. + await iap.finishTransaction(purchase: _androidPurchase(token)); + + expect(logs.where((log) => log.contains('premium')), hasLength(3)); + expect(logs.where((log) => log.contains(token)), isEmpty); + }); + test('finishes Apple transactions and rejects unsupported platforms', () async { final calls = []; diff --git a/libraries/godot-iap/Example/export_presets.cfg b/libraries/godot-iap/Example/export_presets.cfg index 91bda371d..10ce581a6 100644 --- a/libraries/godot-iap/Example/export_presets.cfg +++ b/libraries/godot-iap/Example/export_presets.cfg @@ -296,6 +296,7 @@ script_export_mode=2 [preset.1.options] +openiap/horizon_app_id="31705015229097839" custom_template/debug="" custom_template/release="" gradle_build/use_gradle_build=true diff --git a/libraries/godot-iap/Example/iap_manager.gd b/libraries/godot-iap/Example/iap_manager.gd index e298b27ac..568fac5a2 100644 --- a/libraries/godot-iap/Example/iap_manager.gd +++ b/libraries/godot-iap/Example/iap_manager.gd @@ -75,17 +75,23 @@ func _fetch_products_delayed() -> void: ## Clear pending purchases that weren't finished (e.g., app crashed after purchase) func _clear_pending_purchases() -> void: print("[IAPManager] Checking for pending purchases...") - var available_result = await GodotIapPlugin.get_available_purchases_result() - if not available_result.get("success", false): - push_warning( - "[IAPManager] Could not query pending purchases: %s (%s)" - % [ - available_result.get("error", "Unknown store error"), - available_result.get("code", "unknown"), - ] - ) - return - var pending_purchases = available_result.get("purchases", []) + var pending_purchases: Array = [] + if OS.get_name() == "iOS": + # Available purchases also lists every expired renewal; only an + # unfinished transaction is still pending on iOS. + pending_purchases = await GodotIapPlugin.get_pending_transactions_ios() + else: + var available_result = await GodotIapPlugin.get_available_purchases_result() + if not available_result.get("success", false): + push_warning( + "[IAPManager] Could not query pending purchases: %s (%s)" + % [ + available_result.get("error", "Unknown store error"), + available_result.get("code", "unknown"), + ] + ) + return + pending_purchases = available_result.get("purchases", []) if pending_purchases.size() == 0: print("[IAPManager] No pending purchases found") @@ -105,19 +111,9 @@ func _clear_pending_purchases() -> void: print("[IAPManager] Skipping acknowledged purchase: %s" % product_id) continue - # Determine if consumable - var is_consumable = (product_id == PRODUCT_10_BULBS or product_id == PRODUCT_30_BULBS) - - # The sweep finishes purchases the same way the live path does, so it - # must clear the same verification gate first. - if not await _verify_purchase(purchase_dict, product_id): - print("[IAPManager] Leaving pending purchase unverified: %s" % product_id) - continue - - print("[IAPManager] Finishing pending purchase: %s (consumable: %s)" % [product_id, is_consumable]) - - var result = await GodotIapPlugin.finish_transaction_dict(purchase_dict, is_consumable) - print("[IAPManager] finish_transaction_dict result: success=%s" % result.success) + # A recovered purchase takes the live path, so it is handled like a + # live one. + await _on_purchase_updated(purchase_dict) print("[IAPManager] Pending purchases cleared") @@ -253,7 +249,14 @@ func _on_purchase_updated(purchase: Dictionary) -> void: var consumable = (product_id == PRODUCT_10_BULBS or product_id == PRODUCT_30_BULBS) # Use the raw purchase dictionary directly to preserve transactionId - await GodotIapPlugin.finish_transaction_dict(purchase, consumable) + var finished = await GodotIapPlugin.finish_transaction_dict(purchase, consumable) + if finished == null or not finished.success: + # The store redelivers an unfinished transaction; crediting now + # would credit it again on that redelivery. + if transaction_id != "": + _processed_transactions.erase(transaction_id) + push_warning("[IAPManager] Finish failed, leaving %s for redelivery" % product_id) + return purchase_completed.emit(product_id) diff --git a/libraries/godot-iap/Example/tests/test_android_store.gd b/libraries/godot-iap/Example/tests/test_android_store.gd new file mode 100644 index 000000000..0b0831cb0 --- /dev/null +++ b/libraries/godot-iap/Example/tests/test_android_store.gd @@ -0,0 +1,131 @@ +extends SceneTree +## Export store mapping tests for addons/godot-iap/android_store.gd. +## Run with: godot --headless --path Example --script res://tests/test_android_store.gd + +const AndroidStore = preload("res://addons/godot-iap/android_store.gd") + +var _passed := 0 +var _failed := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + print("\nRunning Android store mapping tests...\n") + _check("auto normalizes to auto", AndroidStore.normalize("auto") == "auto") + _check("blank normalizes to auto", AndroidStore.normalize(" ") == "auto") + _check("null normalizes to auto", AndroidStore.normalize(null) == "auto") + _check("quest is a Horizon alias", AndroidStore.normalize("Quest") == "horizon") + _check("fire-os is an Amazon alias", AndroidStore.normalize("fire-os") == "amazon") + _check("gms is a Play alias", AndroidStore.normalize("gms") == "play") + _check("unknown values name no store", AndroidStore.normalize("bogus") == "") + + var play := "io.github.hyochan.openiap:openiap-google:3.5.2" + _check("auto exports the Play artifact", AndroidStore.artifact(play, "auto") == play) + _check( + "horizon swaps the artifact", + AndroidStore.artifact(play, "horizon") == "io.github.hyochan.openiap:openiap-google-horizon:3.5.2" + ) + _check( + "amazon swaps the artifact", + AndroidStore.artifact(play, "amazon") == "io.github.hyochan.openiap:openiap-google-amazon:3.5.2" + ) + var other := "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0" + _check("other dependencies pass through", AndroidStore.artifact(other, "horizon") == other) + + _check( + "a Horizon app id becomes manifest meta-data", + AndroidStore.horizon_app_id_meta_data(" 31705015229097839 ") + == '' + ) + _check("a Horizon export is tagged", AndroidStore.store_feature("horizon") == "openiap_store_horizon") + _check("an Amazon export is tagged", AndroidStore.store_feature("amazon") == "openiap_store_amazon") + _check("a Play export is untagged", AndroidStore.store_feature("play") == "") + _check("a blank Horizon app id adds nothing", AndroidStore.horizon_app_id_meta_data("") == "") + _check("a null Horizon app id adds nothing", AndroidStore.horizon_app_id_meta_data(null) == "") + _check( + "a non-numeric Horizon app id adds nothing", + AndroidStore.horizon_app_id_meta_data('1" android:exported="true') == "" + ) + + var one := "List of devices attached\r\nAAA\tdevice\r\nCCC\tunauthorized\r\nDDD\toffline\r\n" + var two := "List of devices attached\nAAA\tdevice\nBBB\tdevice\n" + _check("the one ready device is selected", AndroidStore.pick_serial(one, "") == "AAA") + _check("two devices select nothing", AndroidStore.pick_serial(two, "") == "") + _check("ANDROID_SERIAL picks among several", AndroidStore.pick_serial(two, "BBB") == "BBB") + _check("an unattached ANDROID_SERIAL selects nothing", AndroidStore.pick_serial(two, "ZZZ") == "") + _check("no device selects nothing", AndroidStore.pick_serial("List of devices attached\n", "") == "") + + _check( + "a Horizon OS feature is Horizon", + AndroidStore.classify("feature:android.hardware.wifi\nfeature:horizonos.software.horizon_os\n", "Oculus") == "horizon" + ) + _check("a standalone VR feature is Horizon", AndroidStore.classify("feature:oculus.hardware.standalone_vr", "") == "horizon") + _check("a Fire TV feature is Amazon", AndroidStore.classify("feature:amazon.hardware.fire_tv", "") == "amazon") + _check("an Amazon manufacturer is Amazon", AndroidStore.classify("feature:android.hardware.wifi", " Amazon ") == "amazon") + _check("anything else is Play", AndroidStore.classify("feature:android.hardware.wifi", "Google") == "play") + + OS.set_environment("ANDROID_SERIAL", "ABSENT") + var no_adb := AndroidStore.resolve_auto(true, "/nonexistent/adb") + _check( + "a debug export with no adb is Play, without blaming ANDROID_SERIAL", + no_adb.store == "play" and no_adb.source == "default" and no_adb.reason == "adb did not answer" + ) + OS.unset_environment("ANDROID_SERIAL") + if OS.get_name() == "Windows": + print(" - skipped the connected-device cases: fake-adb is a POSIX shell script") + else: + _run_device_cases() + + print("\nAndroid store tests: %d passed, %d failed\n" % [_passed, _failed]) + quit(0 if _failed == 0 else 1) + + +# The Gradle resolver's own fixture, so both resolvers read the same adb answers. +func _run_device_cases() -> void: + var adb := ProjectSettings.globalize_path("res://").path_join( + "../../../packages/google/compatibility/store-resolver/fake-adb" + ).simplify_path() + _device("QUEST1", "feature:oculus.hardware.standalone_vr", "Oculus") + _resolves("a Quest selects horizon", true, adb, "horizon/device") + _resolves("a release export ignores the device", false, adb, "play/default") + _device("GHOST1", "", "") + _resolves("a device that stops answering", true, adb, "play/default") + _device("FIRE1", "feature:amazon.hardware.fire_tv", "Amazon") + _resolves("a Fire device selects amazon", true, adb, "amazon/device") + _device("FIRE2", "", "Amazon") + _resolves("Amazon without the TV feature", true, adb, "amazon/device") + _device("PIXEL1", "feature:android.hardware.nfc", "Google") + _resolves("anything else is play", true, adb, "play/device") + + _device("QUEST1", "feature:oculus.hardware.standalone_vr", "Oculus") + OS.set_environment("FAKE_ADB_DEVICES", "QUEST1 PIXEL1") + _resolves("two devices select nothing", true, adb, "play/default") + OS.set_environment("ANDROID_SERIAL", "QUEST1") + _resolves("ANDROID_SERIAL picks one of them", true, adb, "horizon/device") + OS.set_environment("ANDROID_SERIAL", "ABSENT") + _resolves("an absent serial selects nothing", true, adb, "play/default") + OS.unset_environment("ANDROID_SERIAL") + + +func _device(serial: String, features: String, manufacturer: String) -> void: + OS.set_environment("FAKE_ADB_DEVICES", serial) + OS.set_environment("FAKE_ADB_%s_FEATURES" % serial, features) + OS.set_environment("FAKE_ADB_%s_MANUFACTURER" % serial, manufacturer) + + +func _resolves(name: String, debug: bool, adb: String, expected: String) -> void: + var resolution := AndroidStore.resolve_auto(debug, adb) + var actual := "%s/%s" % [resolution.store, resolution.source] + _check("%s (%s)" % [name, actual], actual == expected) + + +func _check(name: String, ok: bool) -> void: + if ok: + _passed += 1 + print(" ✓ %s" % name) + else: + _failed += 1 + printerr(" ✗ %s" % name) diff --git a/libraries/godot-iap/Example/tests/test_godot_iap.gd b/libraries/godot-iap/Example/tests/test_godot_iap.gd index fc9426d27..039c3cc5a 100644 --- a/libraries/godot-iap/Example/tests/test_godot_iap.gd +++ b/libraries/godot-iap/Example/tests/test_godot_iap.gd @@ -781,6 +781,13 @@ func test_store_and_stub_mode_helpers() -> void: GodotIapPlugin._platform = "Android" _assert_equal(GodotIapPlugin.get_store(), Types.IapStore.GOOGLE, "Android should map to the GOOGLE store") + # A Horizon or Amazon export is tagged with the store it linked. + var original_has_feature = GodotIapPlugin._has_feature + GodotIapPlugin._has_feature = func(tag): return tag == "openiap_store_horizon" + _assert_equal(GodotIapPlugin.get_store(), Types.IapStore.HORIZON, "A Horizon export should report the HORIZON store") + GodotIapPlugin._has_feature = func(tag): return tag == "openiap_store_amazon" + _assert_equal(GodotIapPlugin.get_store(), Types.IapStore.AMAZON, "An Amazon export should report the AMAZON store") + GodotIapPlugin._has_feature = original_has_feature GodotIapPlugin._platform = "iOS" _assert_equal(GodotIapPlugin.get_store(), Types.IapStore.APPLE, "iOS should map to the APPLE store") GodotIapPlugin._platform = "Linux" diff --git a/libraries/godot-iap/Makefile b/libraries/godot-iap/Makefile index 032d09726..be3d6bc86 100644 --- a/libraries/godot-iap/Makefile +++ b/libraries/godot-iap/Makefile @@ -217,6 +217,7 @@ test: @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_api_surface.gd @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_envelope_parsing.gd @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_godot_iap.gd + @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_android_store.gd @echo "$(GREEN)✓ Tests complete$(NC)" # Clean build artifacts diff --git a/libraries/godot-iap/README.md b/libraries/godot-iap/README.md index 1ef12b6d9..7fcc750df 100644 --- a/libraries/godot-iap/README.md +++ b/libraries/godot-iap/README.md @@ -105,6 +105,11 @@ keep the platform default; the published addon ships the release AAR under both names, so add your own debug `networkSecurityConfig` if you need the local vertical there. +An export preset carrying a store value the addon does not recognise logs an +error and falls back to Play, rather than failing the export as the Gradle and +MAUI builds do; the export UI only offers valid values, so this is reachable by +hand-editing the preset. + The store panel's top button cycles verification in this order: 1. **None (Skip)** — skip verification. diff --git a/libraries/godot-iap/addons/godot-iap/android_store.gd b/libraries/godot-iap/addons/godot-iap/android_store.gd new file mode 100644 index 000000000..b4377d9c9 --- /dev/null +++ b/libraries/godot-iap/addons/godot-iap/android_store.gd @@ -0,0 +1,115 @@ +## Android store selection for exports, by the same rules as +## packages/google/gradle/openiap-store.gradle. +extends RefCounted + +const STORES: PackedStringArray = ["auto", "play", "horizon", "amazon"] +const ALIASES := { + "auto": "auto", + "play": "play", + "google": "play", + "gplay": "play", + "googleplay": "play", + "google-play": "play", + "gms": "play", + "horizon": "horizon", + "meta": "horizon", + "quest": "horizon", + "amazon": "amazon", + "fire": "amazon", + "fireos": "amazon", + "fire-os": "amazon", +} + + +## Returns the store id, or "" when the value names no store. +static func normalize(value: Variant) -> String: + if value == null: + return "auto" + var key := str(value).strip_edges().to_lower() + if key.is_empty(): + return "auto" + return ALIASES.get(key, "") + + +const HORIZON_APP_ID_META_DATA := "com.meta.horizon.platform.HORIZON_APP_ID" + +## Export feature tag for the store an Android export linked, which get_store() +## reads at runtime. Play is the untagged default. +static func store_feature(store: String) -> String: + return "openiap_store_" + store if store in ["horizon", "amazon"] else "" + + +## The manifest entry the Horizon SDK reads the app id from, or "" unless the +## value is the numeric id from Meta Horizon Developer Hub. +static func horizon_app_id_meta_data(app_id: Variant) -> String: + var id := "" if app_id == null else str(app_id).strip_edges() + if RegEx.create_from_string("^[0-9]+$").search(id) == null: + return "" + return '' % [HORIZON_APP_ID_META_DATA, id] + + +## Rewrites the openiap-google coordinate for the store; other coordinates pass through. +static func artifact(coordinate: String, store: String) -> String: + var resolved := "play" if store == "auto" else store + var suffix := "" if resolved == "play" else "-" + resolved + return coordinate.replace(":openiap-google:", ":openiap-google" + suffix + ":") + + +## The serial `adb devices` selects: ANDROID_SERIAL when it is attached, +## otherwise the only device. "" when that is absent or ambiguous. +static func pick_serial(devices_output: String, requested: String) -> String: + var row := RegEx.create_from_string("^(\\S+)\\s+device$") + var serials := PackedStringArray() + for line in devices_output.split("\n"): + # Windows adb ends lines with CR, which the serial would otherwise keep. + var found := row.search(line.replace("\r", "").strip_edges()) + if found: + serials.append(found.get_string(1)) + var wanted := requested.strip_edges() + if not wanted.is_empty(): + return wanted if serials.has(wanted) else "" + return serials[0] if serials.size() == 1 else "" + + +## The store a device's feature list and manufacturer point to. +static func classify(features: String, manufacturer: String) -> String: + if features.contains("feature:horizonos.software.horizon_os") or features.contains("feature:oculus.hardware.standalone_vr"): + return "horizon" + if features.contains("feature:amazon.hardware.fire_tv") or manufacturer.strip_edges().to_lower() == "amazon": + return "amazon" + return "play" + + +## Resolves auto: a debug export follows the one connected device, and a +## release export never looks, so its SDK cannot depend on what is plugged in. +static func resolve_auto(debug: bool, adb: String) -> Dictionary: + if not debug: + return {"store": "play", "source": "default", "reason": "release export"} + var listing := _adb_text(adb, ["devices"]) + # adb always prints a header, so no output means adb itself failed. + if listing.is_empty(): + return {"store": "play", "source": "default", "reason": "adb did not answer"} + var requested := OS.get_environment("ANDROID_SERIAL").strip_edges() + var serial := pick_serial(listing, requested) + if serial.is_empty(): + if not requested.is_empty(): + push_warning("[GodotIap] ANDROID_SERIAL=%s is not attached; not selecting a store from a device" % requested) + return {"store": "play", "source": "default", "reason": "no single connected device"} + var features := _adb_text(adb, ["-s", serial, "shell", "pm", "list", "features"]) + var manufacturer := _adb_text(adb, ["-s", serial, "shell", "getprop", "ro.product.manufacturer"]).strip_edges() + # A device that dropped after the listing answers with nothing, which is + # not a signal that it meant Play. + if features.strip_edges().is_empty() and manufacturer.is_empty(): + return {"store": "play", "source": "default", "reason": "%s stopped responding" % serial} + return { + "store": classify(features, manufacturer), + "source": "device", + "reason": "device %s, manufacturer %s" % [serial, manufacturer if not manufacturer.is_empty() else "unknown"], + } + + +static func _adb_text(adb: String, args: PackedStringArray) -> String: + var output := [] + if OS.execute(adb, args, output) != 0 or output.is_empty(): + return "" + return str(output[0]) diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index bdd5ed078..6c63de9b3 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -11,6 +11,7 @@ class_name GodotIapWrapper # Types from OpenIAP spec const Types = preload("types.gd") +const AndroidStore = preload("android_store.gd") const APPLE_PLATFORMS := ["iOS", "macOS"] const APPLE_ASYNC_RESULT_CACHE_LIMIT := 64 @@ -77,6 +78,8 @@ var _apple_async_ui_timeout_seconds := 300.0 # Platform detection var _platform: String = "" +## OS.has_feature, swappable in tests: an editor run carries no export tags. +var _has_feature: Callable = Callable(OS, "has_feature") func _is_apple() -> bool: @@ -1592,7 +1595,7 @@ func get_promoted_product_ios() -> Variant: return Types.ProductIOS.from_dict(parsed) return null -## Check if can present external purchase notice (iOS 18.2+). +## Check if can present external purchase notice (iOS 17.4+). ## @return bool - true if external purchase notice can be presented ## ## See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios @@ -1602,7 +1605,7 @@ func can_present_external_purchase_notice_ios() -> bool: return payload.get("success", false) and payload.get("canPresent", false) return false -## Present external purchase notice sheet (iOS 18.2+). +## Present external purchase notice sheet (iOS 17.4+). ## @return Types.ExternalPurchaseNoticeResultIOS ## ## See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios @@ -1618,7 +1621,7 @@ func present_external_purchase_notice_sheet_ios() -> Variant: var default_result = Types.ExternalPurchaseNoticeResultIOS.new() return default_result -## Present external purchase link (iOS 18.2+). +## Present external purchase link. ## @param url: String - external purchase URL ## @return Types.ExternalPurchaseLinkResultIOS ## @@ -2160,6 +2163,11 @@ func is_stub_mode() -> bool: ## Returns Types.IapStore enum value func get_store() -> Variant: if _platform == "Android": + # Every store is an Android build; the export tags the one it linked. + if _has_feature.call(AndroidStore.store_feature("horizon")): + return Types.IapStore.HORIZON + if _has_feature.call(AndroidStore.store_feature("amazon")): + return Types.IapStore.AMAZON return Types.IapStore.GOOGLE elif _is_apple(): return Types.IapStore.APPLE diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd b/libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd index b4ea7c7d0..f6bae9ea4 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd @@ -33,6 +33,9 @@ class GodotIapExportPlugin extends EditorExportPlugin: # Untracked developer settings. The example includes it so a debug export can # reach a local IAPKit server; a release export must never carry the key. const LOCAL_SETTINGS_PATH = "res://iapkit.cfg" + const AndroidStore = preload("res://addons/godot-iap/android_store.gd") + const ANDROID_STORE_OPTION = "openiap/android_store" + const HORIZON_APP_ID_OPTION = "openiap/horizon_app_id" const IOS_FRAMEWORKS: Array[String] = [ "res://addons/godot-iap/bin/ios/GodotIap.framework", "res://addons/godot-iap/bin/ios/SwiftGodotRuntime.framework", @@ -93,8 +96,92 @@ class GodotIapExportPlugin extends EditorExportPlugin: else: return PackedStringArray(["res://addons/godot-iap/android/GodotIap.release.aar"]) + func _get_export_options(platform: EditorExportPlatform) -> Array[Dictionary]: + if not (platform is EditorExportPlatformAndroid): + return [] + return [{ + "option": { + "name": ANDROID_STORE_OPTION, + "type": TYPE_STRING, + "hint": PROPERTY_HINT_ENUM, + "hint_string": ",".join(AndroidStore.STORES), + }, + "default_value": "auto", + }, { + "option": { + "name": HORIZON_APP_ID_OPTION, + "type": TYPE_STRING, + }, + "default_value": "", + }] + + func _get_android_manifest_application_element_contents(_platform: EditorExportPlatform, _debug: bool) -> String: + var value = get_option(HORIZON_APP_ID_OPTION) + var app_id := "" if value == null else str(value).strip_edges() + if app_id.is_empty(): + return "" + var meta_data := AndroidStore.horizon_app_id_meta_data(app_id) + if meta_data.is_empty(): + push_error("[GodotIap] %s must be the numeric app id from Meta Horizon Developer Hub" % HORIZON_APP_ID_OPTION) + return meta_data + + # One answer per export: the dependencies and the feature tag must agree, and + # the device is probed once. + var _android_stores := {} + + func _export_end() -> void: + _android_stores.clear() + + ## The store this Android export links, or "" when the option names none. + func _android_store(debug: bool) -> String: + var option = get_option(ANDROID_STORE_OPTION) + var key := "%s|%s" % [debug, option] + if _android_stores.has(key): + return _android_stores[key] + var store := AndroidStore.normalize(option) + if store == "auto": + var resolution := AndroidStore.resolve_auto(debug, _adb_path() if debug else "") + store = resolution.store + print("[GodotIap] openiap: store=%s (source=%s; %s)" % [resolution.store, resolution.source, resolution.reason]) + elif not store.is_empty(): + print("[GodotIap] openiap: store=%s (source=explicit; %s)" % [store, ANDROID_STORE_OPTION]) + _android_stores[key] = store + return store + + func _get_export_features(platform: EditorExportPlatform, debug: bool) -> PackedStringArray: + if not (platform is EditorExportPlatformAndroid): + return PackedStringArray() + var feature := AndroidStore.store_feature(_android_store(debug)) + return PackedStringArray([feature]) if not feature.is_empty() else PackedStringArray() + func _get_android_dependencies(platform: EditorExportPlatform, debug: bool) -> PackedStringArray: - return _read_android_remote_dependencies() + var store := _android_store(debug) + if store.is_empty(): + # Godot's export API cannot abort here, so fall back to Play (the + # untagged default) instead of shipping the AAR without OpenIAP classes. + push_error("[GodotIap] %s must be one of: %s; falling back to Play" % [ANDROID_STORE_OPTION, ", ".join(AndroidStore.STORES)]) + store = "play" + var dependencies := PackedStringArray() + for dependency in _read_android_remote_dependencies(): + dependencies.append(AndroidStore.artifact(dependency, store)) + return dependencies + + # The editor's SDK setting first, then the Gradle resolver's fallbacks. + func _adb_path() -> String: + var roots := PackedStringArray() + var settings := EditorInterface.get_editor_settings() + if settings.has_setting("export/android/android_sdk_path"): + roots.append(str(settings.get_setting("export/android/android_sdk_path"))) + roots.append(OS.get_environment("ANDROID_HOME")) + roots.append(OS.get_environment("ANDROID_SDK_ROOT")) + for root in roots: + if root.strip_edges().is_empty(): + continue + for name in ["adb", "adb.exe"]: + var candidate := root.path_join("platform-tools").path_join(name) + if FileAccess.file_exists(candidate): + return candidate + return "adb" func _read_android_remote_dependencies() -> PackedStringArray: if not FileAccess.file_exists(ANDROID_GDAP_PATH): diff --git a/libraries/kmp-iap/README.md b/libraries/kmp-iap/README.md index a05207ee3..b27bd199f 100644 --- a/libraries/kmp-iap/README.md +++ b/libraries/kmp-iap/README.md @@ -40,6 +40,17 @@ dependencies { Use the latest version from [Maven Central](https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap). +kmp-iap publishes a Play, Horizon, and Amazon Android build. Apply the OpenIAP +Gradle plugin in `settings.gradle.kts` so Gradle can pick one for every module; +see [Pick the Android store](https://openiap.dev/docs/setup/kmp#android-store). + +```kotlin +// settings.gradle.kts +plugins { + id("io.github.hyochan.openiap") version "" +} +``` + ## 🚀 Quick Start ### Option 1: Using Global Instance (Simple) diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt index ac7915406..49cf087b6 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -133,12 +133,13 @@ fun AlternativeBillingScreen(navController: NavController) { Date: $dateText """.trimIndent() - // Finish transaction + // Demo: finishes without verification; only bulb packs are consumed. scope.launch { try { kmpIapInstance.finishTransaction( purchase = purchase, - isConsumable = true + // The listener also receives a badge redelivered from elsewhere. + isConsumable = purchase.productId in ConsumableProductIds ) } catch (e: Exception) { println("Failed to finish transaction: ${e.message}") @@ -243,7 +244,7 @@ fun AlternativeBillingScreen(navController: NavController) { isProcessing = true try { - // For iOS 18.2+, present notice sheet first if available + // Present the notice sheet first when available (iOS 17.4+) if (kmpIapInstance.canPresentExternalPurchaseNoticeIOS()) { purchaseResult = "📋 Presenting external purchase notice..." diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AvailablePurchasesScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AvailablePurchasesScreen.kt index 112b09ab9..3c3663887 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AvailablePurchasesScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AvailablePurchasesScreen.kt @@ -352,6 +352,9 @@ fun AvailablePurchasesScreen(navController: NavController) { scope.launch { consumingPurchaseId = purchase.id + // Manual tool: finishes without verification to clear a stuck + // transaction. Consuming the badge would drop its entitlement. + val isConsumable = purchase.productId in ConsumableProductIds try { // Debug log when (purchase) { @@ -363,9 +366,9 @@ fun AvailablePurchasesScreen(navController: NavController) { } } - kmpIAP.finishTransaction(purchase.toPurchaseInput(), isConsumable = !isSubscription) + kmpIAP.finishTransaction(purchase.toPurchaseInput(), isConsumable = isConsumable) - val action = if (isSubscription) "acknowledged" else "consumed" + val action = if (isConsumable) "consumed" else "acknowledged" consumeResult = "✅ Purchase $action: ${purchase.productId}" // Wait a bit before refreshing to let the system process @@ -380,7 +383,7 @@ fun AvailablePurchasesScreen(navController: NavController) { println("Failed to refresh purchases: ${e.message}") } } catch (e: Exception) { - val action = if (isSubscription) "acknowledge" else "consume" + val action = if (isConsumable) "consume" else "acknowledge" println("❌ Failed to finish transaction: ${e.message}") consumeResult = "❌ Failed to $action: ${e.message}" } finally { @@ -684,13 +687,16 @@ fun PurchaseCard( color = Color.White ) } else { - val buttonText = if (isSubscription) { - when (purchase) { + val buttonText = when { + isSubscription -> when (purchase) { is PurchaseAndroid -> "Acknowledge Subscription" is PurchaseIOS -> "Finish Transaction" } - } else { - "Consume Purchase" + purchase.productId in ConsumableProductIds -> "Consume Purchase" + else -> when (purchase) { + is PurchaseAndroid -> "Acknowledge Purchase" + is PurchaseIOS -> "Finish Transaction" + } } Text(buttonText) } diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt index 1f4f6d2cc..7e26024d5 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -102,140 +102,149 @@ fun PurchaseFlowScreen(navController: NavController) { var showVerificationDialog by remember { mutableStateOf(false) } var verificationResult by remember { mutableStateOf(null) } - // Register purchase event listeners - LaunchedEffect(Unit) { - launch { - kmpIapInstance.purchaseUpdatedListener.collect { purchase -> - currentPurchase = purchase - - when (purchase.purchaseState) { - PurchaseState.Purchased -> { - isProcessing = false + // Live purchases and transactions StoreKit replayed before this screen + // started collecting take the same verify-then-finish path; the id set + // drops a copy that arrives while the first is still in flight. + val handledTransactionIds = remember { mutableSetOf() } + fun handlePurchased(purchase: Purchase) { + if (purchase.id.isNotEmpty() && !handledTransactionIds.add(purchase.id)) return + isProcessing = false + + println( + "[KMP-IAP Example] Purchase succeeded: " + + "productId=${purchase.productId}, credential=${credentialStatus(purchase.purchaseToken)}" + ) - println( - "[KMP-IAP Example] Purchase succeeded: " + - "productId=${purchase.productId}, credential=${credentialStatus(purchase.purchaseToken)}" - ) + val dateText = Instant.fromEpochMilliseconds(purchase.transactionDate.toLong()) + .toLocalDateTime(TimeZone.currentSystemDefault()) + purchaseResult = """ + ✅ Purchase successful (${purchase.store}) + Product: ${purchase.productId} + Transaction ID: ${purchase.id.ifEmpty { "N/A" }} + Date: $dateText + Purchase credential: ${credentialStatus(purchase.purchaseToken)} +""".trimIndent() - val dateText = Instant.fromEpochMilliseconds(purchase.transactionDate.toLong()) - .toLocalDateTime(TimeZone.currentSystemDefault()) - purchaseResult = """ - ✅ Purchase successful (${purchase.store}) - Product: ${purchase.productId} - Transaction ID: ${purchase.id.ifEmpty { "N/A" }} - Date: $dateText - Purchase credential: ${credentialStatus(purchase.purchaseToken)} - """.trimIndent() - - scope.launch { - val verificationMethodAtStart = verificationMethod - var iapkitVerificationOk = true - // Verify purchase based on selected method - if (verificationMethodAtStart != VerificationMethod.None) { - verificationResult = "🔄 Verifying purchase..." - try { - when (verificationMethodAtStart) { - VerificationMethod.Local -> { - val isIos = getCurrentPlatform() == IapPlatform.Ios - val result = kmpIapInstance.verifyPurchase( - VerifyPurchaseProps( - apple = if (isIos) VerifyPurchaseAppleOptions(sku = purchase.productId) else null, - google = if (!isIos) VerifyPurchaseGoogleOptions( - sku = purchase.productId, - accessToken = "your_google_api_access_token", // Obtain from your backend for production use - packageName = "your.app.package.name", // Your app's package name - purchaseToken = purchase.purchaseToken ?: "", - isSub = false - ) else null - ) + scope.launch { + val verificationMethodAtStart = verificationMethod + var iapkitVerificationOk = true + // Verify purchase based on selected method + if (verificationMethodAtStart != VerificationMethod.None) { + verificationResult = "🔄 Verifying purchase..." + try { + when (verificationMethodAtStart) { + VerificationMethod.Local -> { + val isIos = getCurrentPlatform() == IapPlatform.Ios + val result = kmpIapInstance.verifyPurchase( + VerifyPurchaseProps( + apple = if (isIos) VerifyPurchaseAppleOptions(sku = purchase.productId) else null, + google = if (!isIos) VerifyPurchaseGoogleOptions( + sku = purchase.productId, + accessToken = "your_google_api_access_token", // Obtain from your backend for production use + packageName = "your.app.package.name", // Your app's package name + purchaseToken = purchase.purchaseToken ?: "", + isSub = false + ) else null + ) + ) + verificationResult = when (result) { + is VerifyPurchaseResultIOS -> "📱 Local Verification (iOS):\n" + + "Valid: ${result.isValid}\n" + + "Purchase credential: ${credentialStatus(purchase.purchaseToken)}" + is VerifyPurchaseResultAndroid -> "📱 Local Verification (Android):\n" + + "Product: ${result.productId}\n" + + "Receipt ID: ${credentialStatus(result.receiptId)}" + is VerifyPurchaseResultHorizon -> "📱 Horizon Verification:\n" + + "Valid: ${result.isValid}\n" + + "Grant Time: ${result.grantTime ?: "N/A"}" + } + } + VerificationMethod.IAPKitLocal, VerificationMethod.IAPKit -> { + val apiKey = AppConfig.iapkitApiKey + val localBaseUrl = AppConfig.iapkitBaseUrl + val label = verificationMethodAtStart.label + if (verificationMethodAtStart == VerificationMethod.IAPKitLocal && + localBaseUrl.isBlank() + ) { + iapkitVerificationOk = false + verificationResult = "❌ IAPKIT_BASE_URL not configured.\n" + + "Set IAPKIT_BASE_URL in .env (Android) or Secrets.xcconfig (iOS)." + } else if (apiKey.isBlank()) { + iapkitVerificationOk = false + verificationResult = "❌ IAPKit API key not configured.\n" + + "Set IAPKIT_API_KEY in .env (Android) or Secrets.xcconfig (iOS)." + } else { + val jwsOrToken = purchase.purchaseToken ?: "" + if (jwsOrToken.isEmpty() && purchase.store != IapStore.Horizon) { + iapkitVerificationOk = false + verificationResult = "❌ No purchase token available for verification" + } else { + val isIos = getCurrentPlatform() == IapPlatform.Ios + val result = kmpIapInstance.verifyPurchaseWithProvider( + VerifyPurchaseWithProviderProps( + provider = PurchaseVerificationProvider.Iapkit, + iapkit = RequestVerifyPurchaseWithIapkitProps( + amazon = if (purchase.store == IapStore.Amazon) RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId = jwsOrToken, + sandbox = AppConfig.amazonRvsSandbox, + // IAPKit rejects an Amazon receipt without the buyer's id. + userId = (purchase as? PurchaseAndroid)?.userIdAmazon, + ) else null, + apiKey = apiKey, + apple = if (isIos) RequestVerifyPurchaseWithIapkitAppleProps(jws = jwsOrToken) else null, + baseUrl = if (verificationMethodAtStart == VerificationMethod.IAPKitLocal) localBaseUrl else null, + google = if (!isIos && purchase.store == IapStore.Google) RequestVerifyPurchaseWithIapkitGoogleProps(purchaseToken = jwsOrToken) else null, + horizon = if (purchase.store == IapStore.Horizon) RequestVerifyPurchaseWithIapkitHorizonProps(sku = purchase.productId) else null, ) - verificationResult = when (result) { - is VerifyPurchaseResultIOS -> "📱 Local Verification (iOS):\n" + - "Valid: ${result.isValid}\n" + - "Purchase credential: ${credentialStatus(purchase.purchaseToken)}" - is VerifyPurchaseResultAndroid -> "📱 Local Verification (Android):\n" + - "Product: ${result.productId}\n" + - "Receipt ID: ${credentialStatus(result.receiptId)}" - is VerifyPurchaseResultHorizon -> "📱 Horizon Verification:\n" + - "Valid: ${result.isValid}\n" + - "Grant Time: ${result.grantTime ?: "N/A"}" - } - } - VerificationMethod.IAPKitLocal, VerificationMethod.IAPKit -> { - val apiKey = AppConfig.iapkitApiKey - val localBaseUrl = AppConfig.iapkitBaseUrl - val label = verificationMethodAtStart.label - if (verificationMethodAtStart == VerificationMethod.IAPKitLocal && - localBaseUrl.isBlank() - ) { - iapkitVerificationOk = false - verificationResult = "❌ IAPKIT_BASE_URL not configured.\n" + - "Set IAPKIT_BASE_URL in .env (Android) or Secrets.xcconfig (iOS)." - } else if (apiKey.isBlank()) { - iapkitVerificationOk = false - verificationResult = "❌ IAPKit API key not configured.\n" + - "Set IAPKIT_API_KEY in .env (Android) or Secrets.xcconfig (iOS)." - } else { - val jwsOrToken = purchase.purchaseToken ?: "" - if (jwsOrToken.isEmpty() && purchase.store != IapStore.Horizon) { - iapkitVerificationOk = false - verificationResult = "❌ No purchase token available for verification" - } else { - val isIos = getCurrentPlatform() == IapPlatform.Ios - val result = kmpIapInstance.verifyPurchaseWithProvider( - VerifyPurchaseWithProviderProps( - provider = PurchaseVerificationProvider.Iapkit, - iapkit = RequestVerifyPurchaseWithIapkitProps( - amazon = if (purchase.store == IapStore.Amazon) RequestVerifyPurchaseWithIapkitAmazonProps( - receiptId = jwsOrToken, - sandbox = AppConfig.amazonRvsSandbox, - // IAPKit rejects an Amazon receipt without the buyer's id. - userId = (purchase as? PurchaseAndroid)?.userIdAmazon, - ) else null, - apiKey = apiKey, - apple = if (isIos) RequestVerifyPurchaseWithIapkitAppleProps(jws = jwsOrToken) else null, - baseUrl = if (verificationMethodAtStart == VerificationMethod.IAPKitLocal) localBaseUrl else null, - google = if (!isIos && purchase.store == IapStore.Google) RequestVerifyPurchaseWithIapkitGoogleProps(purchaseToken = jwsOrToken) else null, - horizon = if (purchase.store == IapStore.Horizon) RequestVerifyPurchaseWithIapkitHorizonProps(sku = purchase.productId) else null, - ) - ) - ) - val iapkitResult = result.iapkit - iapkitVerificationOk = iapkitResult?.isValid == true - val statusEmoji = if (iapkitResult?.isValid == true) "✅" else "⚠️" - verificationResult = "$statusEmoji $label Verification:\n" + - "Valid: ${iapkitResult?.isValid ?: false}\n" + - "State: ${iapkitResult?.state?.rawValue ?: "unknown"}\n" + - "Store: ${iapkitResult?.store?.rawValue ?: "unknown"}" - } - } - } - } - } catch (e: Exception) { - if (verificationMethodAtStart.isIapkit) { - iapkitVerificationOk = false - } - verificationResult = "❌ Verification failed: ${e.message}" + ) + ) + val iapkitResult = result.iapkit + iapkitVerificationOk = iapkitResult?.isValid == true + val statusEmoji = if (iapkitResult?.isValid == true) "✅" else "⚠️" + verificationResult = "$statusEmoji $label Verification:\n" + + "Valid: ${iapkitResult?.isValid ?: false}\n" + + "State: ${iapkitResult?.state?.rawValue ?: "unknown"}\n" + + "Store: ${iapkitResult?.store?.rawValue ?: "unknown"}" } } - - if (verificationMethodAtStart.isIapkit && !iapkitVerificationOk) { - purchaseResult = "$purchaseResult\n\n⚠️ Transaction left unfinished because IAPKit verification failed" - return@launch - } - - // Finish the transaction - try { - kmpIapInstance.finishTransaction( - purchase = purchase.toPurchaseInput(), - isConsumable = true - ) - purchaseResult = "$purchaseResult\n\n✅ Transaction finished successfully" - } catch (e: Exception) { - purchaseResult = "$purchaseResult\n\n❌ Failed to finish transaction: ${e.message}" - } } } + } catch (e: Exception) { + if (verificationMethodAtStart.isIapkit) { + iapkitVerificationOk = false + } + verificationResult = "❌ Verification failed: ${e.message}" + } + } + + if (verificationMethodAtStart.isIapkit && !iapkitVerificationOk) { + purchaseResult = "$purchaseResult\n\n⚠️ Transaction left unfinished because IAPKit verification failed" + handledTransactionIds.remove(purchase.id) + return@launch + } + + // Finish the transaction + try { + kmpIapInstance.finishTransaction( + purchase = purchase.toPurchaseInput(), + isConsumable = purchase.productId in ConsumableProductIds + ) + purchaseResult = "$purchaseResult\n\n✅ Transaction finished successfully" + } catch (e: Exception) { + purchaseResult = "$purchaseResult\n\n❌ Failed to finish transaction: ${e.message}" + handledTransactionIds.remove(purchase.id) + } + } + } + + // Register purchase event listeners + LaunchedEffect(Unit) { + launch { + kmpIapInstance.purchaseUpdatedListener.collect { purchase -> + currentPurchase = purchase + + when (purchase.purchaseState) { + PurchaseState.Purchased -> handlePurchased(purchase) PurchaseState.Pending -> { isProcessing = true purchaseResult = "⏳ Purchase is pending user confirmation..." @@ -272,6 +281,10 @@ fun PurchaseFlowScreen(navController: NavController) { initError = "Failed to connect to store" return@launch } + + if (getCurrentPlatform() == IapPlatform.Ios) { + kmpIapInstance.getPendingTransactionsIOS().forEach { handlePurchased(it) } + } // Step 2: Connection successful, load products immediately isConnecting = false diff --git a/libraries/maui-iap/.vscode/run_android.sh b/libraries/maui-iap/.vscode/run_android.sh index f499e89f5..f529dcb20 100755 --- a/libraries/maui-iap/.vscode/run_android.sh +++ b/libraries/maui-iap/.vscode/run_android.sh @@ -57,13 +57,15 @@ fi adb start-server >/dev/null -DEVICE="${MAUI_ANDROID_DEVICE:-}" +# ANDROID_SERIAL is the device every Android tool targets; the device passed +# below as AdbTarget outranks it in the store selection, so honour it here. +DEVICE="${MAUI_ANDROID_DEVICE:-${ANDROID_SERIAL:-}}" if [ -z "$DEVICE" ]; then - DEVICE="$(adb devices -l | awk 'NR > 1 && $2 == "device" && / usb:/ { print $1; exit }')" + DEVICE="$(adb devices -l | tr -d '\r' | awk 'NR > 1 && $2 == "device" && / usb:/ { print $1; exit }')" fi if [ -z "$DEVICE" ]; then - DEVICE="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')" + DEVICE="$(adb devices | tr -d '\r' | awk 'NR > 1 && $2 == "device" { print $1; exit }')" fi if [ -z "$DEVICE" ]; then @@ -97,16 +99,18 @@ rm -f "$APP_DIR/bin/Debug/net10.0-android/$APP_ID-Signed.apk" rm -f "$APP_DIR/bin/Debug/net10.0-android/$RID/$APP_ID.apk" rm -f "$APP_DIR/bin/Debug/net10.0-android/$RID/$APP_ID-Signed.apk" -echo "Building OpenIAP Google Play AAR..." -(cd "$GOOGLE_DIR" && ./gradlew :openiap:assemblePlayRelease) +echo "Building OpenIAP Google store AARs..." +(cd "$GOOGLE_DIR" && ./gradlew :openiap:assemblePlayRelease :openiap:assembleHorizonRelease :openiap:assembleAmazonRelease) echo "Building MAUI Android module AAR..." (cd "$MAUI_ANDROID_DIR" && "$GOOGLE_DIR/gradlew" :openiap:assembleRelease) echo "Building and packaging MAUI Android APK. This can take 1-2 minutes after DLL output..." +# AdbTarget makes the build link the store of this device. dotnet build "$PROJECT" \ -f net10.0-android \ -p:RuntimeIdentifier="$RID" \ + -p:AdbTarget="-s $DEVICE" \ -p:EmbedAssembliesIntoApk=true \ -maxcpucount:1 \ -tl:off \ diff --git a/libraries/maui-iap/AGENTS.md b/libraries/maui-iap/AGENTS.md index 35f378352..48fef7ff1 100644 --- a/libraries/maui-iap/AGENTS.md +++ b/libraries/maui-iap/AGENTS.md @@ -120,11 +120,11 @@ ObjC entry points. Native artifacts must be present **before** `dotnet build` on the binding csprojs: -| Artifact | Built by | Path | -| -------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `openiap-play-release.aar` | `./gradlew :openiap:assemblePlayRelease` (in `packages/google`) | `packages/google/openiap/build/outputs/aar/` | -| `openiap-release.aar` | `../../../packages/google/gradlew :openiap:assembleRelease` (in `libraries/maui-iap/android`) | `libraries/maui-iap/android/openiap/build/outputs/aar/` | -| `OpenIAP.xcframework` | `bash packages/apple/scripts/build-xcframework.sh` (uses xcodegen + the wrapper at `packages/apple/wrapper/`) | `packages/apple/.build/xcframework/` | +| Artifact | Built by | Path | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `openiap--release.aar` (play, horizon, amazon) | `./gradlew :openiap:assemble{Play,Horizon,Amazon}Release` (in `packages/google`) | `packages/google/openiap/build/outputs/aar/` | +| `openiap-release.aar` | `../../../packages/google/gradlew :openiap:assembleRelease` (in `libraries/maui-iap/android`) | `libraries/maui-iap/android/openiap/build/outputs/aar/` | +| `OpenIAP.xcframework` | `bash packages/apple/scripts/build-xcframework.sh` (uses xcodegen + the wrapper at `packages/apple/wrapper/`) | `packages/apple/.build/xcframework/` | CI runs both before invoking the .NET binding builds — see [`.github/workflows/ci-maui-iap.yml`](../../.github/workflows/ci-maui-iap.yml) @@ -140,12 +140,19 @@ so the main package flattens their outputs instead of declaring unpublished The package includes: - binding DLLs in `lib//` -- Android AARs in `lib/net10.0-android36.0/`, limited to OpenIAP-owned artifacts: the - MAUI-owned module AAR and the unbound `openiap-play-release.aar` runtime - dependency -- Android Google Billing, Play Services, Gson, AndroidX, and Kotlin runtime - libraries as normal NuGet `PackageReference` dependencies, not embedded AAR - copies +- the MAUI-owned module AAR in `lib/net10.0-android36.0/` +- every store's `openiap--release.aar` in `android/`, outside `lib/`, + where .NET would link all of them +- `buildTransitive/OpenIap.Maui.targets` and `OpenIap.Maui.props`, which link + one store per app build: `OpenIapStore` (alias `OpenIapAndroidStore`), else + the device a Debug build deploys to, else Play. That store's SDK (Google + Play Billing, the Horizon billing libraries, or the Amazon Appstore SDK) + comes from Maven. `scripts/verify-store-selection.sh` covers the rule. +- Play Services, DataTransport, kotlinx-serialization, Gson, AndroidX, and + Kotlin runtime libraries as normal NuGet `PackageReference` dependencies, not + embedded AAR copies. NuGet cannot vary them per store, so every MAUI build + carries them; Google Play Billing is not one of them, because its binding's + Java wrappers would link it into every store's build - iOS / macCatalyst `OpenIap.Maui.Bindings.iOS.resources.zip` sidecars next to the iOS binding DLLs diff --git a/libraries/maui-iap/Directory.Build.props b/libraries/maui-iap/Directory.Build.props index 6e883911b..92062f78e 100644 --- a/libraries/maui-iap/Directory.Build.props +++ b/libraries/maui-iap/Directory.Build.props @@ -5,11 +5,4 @@ 10.0.90 10.0.10 - - - - obj/stores/$(OpenIapAndroidStore)/ - bin/stores/$(OpenIapAndroidStore)/ - diff --git a/libraries/maui-iap/README.md b/libraries/maui-iap/README.md index e1bc16758..b8f5c9bab 100644 --- a/libraries/maui-iap/README.md +++ b/libraries/maui-iap/README.md @@ -26,10 +26,11 @@ For manual `.csproj` edits, copy the current PackageReference from the [OpenIap.Maui NuGet package page](https://www.nuget.org/packages/OpenIap.Maui). `OpenIap.Maui` is the only NuGet package apps reference. The Android and iOS -binding outputs are flattened into the main NuGet package, while Google -Billing, Play Services, Gson, AndroidX, and Kotlin Android libraries remain -normal NuGet dependencies so apps can deduplicate them with their own package -graph. +binding outputs are flattened into the main NuGet package, while Play Services, +Gson, AndroidX, and Kotlin Android libraries remain normal NuGet dependencies so +apps can deduplicate them with their own package graph. The store SDK itself +(Google Play Billing, the Horizon billing library, or the Amazon Appstore SDK) +is linked when the app builds; see [Android store](#android-store). Stable NuGet releases rebuild the embedded Apple XCFramework with the current App Store-accepted toolchain (Xcode 26.6 / SDK 26.5) and verify every packaged @@ -147,8 +148,8 @@ verification, scoped entitlement reads, and bounded lifecycle refreshes. ```bash cd /path/to/openiap -# Android source runs need the native Google AAR plus the MAUI-owned module AAR. -(cd packages/google && ./gradlew :openiap:assemblePlayRelease) +# Android source runs need every store's Google AAR plus the MAUI-owned module AAR. +(cd packages/google && ./gradlew :openiap:assemblePlayRelease :openiap:assembleHorizonRelease :openiap:assembleAmazonRelease) (cd libraries/maui-iap/android && ../../../packages/google/gradlew :openiap:assembleRelease) cd libraries/maui-iap/example/OpenIap.Maui.Example @@ -160,25 +161,30 @@ dotnet build -t:Run -f net10.0-maccatalyst ``` VS Code launch configurations are in `libraries/maui-iap/.vscode/launch.json`. -The Android launcher builds both AARs before compiling the example app. +The Android launcher builds the AARs and passes its device to the build. -### Android store variants +### Android store -Source builds can select Amazon Appstore or Meta Horizon instead of Google -Play. Build the matching native facade immediately before the .NET build: +A Debug build links the store of the device it deploys to — the IDE's target +(`AdbTarget`), else the one `ANDROID_SERIAL` names, else the only one attached: +a Quest gets Meta Horizon, a Fire device the Amazon Appstore, anything else +Google Play. Only the `Debug` configuration looks at a device, so pin every +build that ships to another store: ```bash -cd libraries/maui-iap/android -../../../packages/google/gradlew :openiap:assembleRelease -PopenIapAndroidStore=amazon -cd .. -dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ - -f net10.0-android \ - -p:OpenIapAndroidStore=amazon +dotnet publish -f net10.0-android -c Release -p:OpenIapStore=horizon ``` -Use `horizon` for Meta Horizon and `play` for Google Play. MAUI keeps each -store's intermediate and output directories separate, preventing a prior -store build from leaking its AAR or manifest into the next variant. +The aliases `google`/`gplay`/`googleplay`/`google-play`/`gms`, `meta`/`quest`, +and `fire`/`fireos`/`fire-os` work too, `OpenIapAndroidStore` still works, and a +value that names no store fails the build. + +MAUI only: NuGet fixes dependencies before the build knows the store, so every +build also carries Play Services and DataTransport (about 3.1 MB, with their +manifest entries) and kotlinx-serialization-json (up to 0.9 MB); the store SDK +itself is linked for the chosen store only. The +[MAUI setup guide](https://openiap.dev/docs/setup/maui#android-store) lists the +manifest entries. ## What's generated vs. hand-written diff --git a/libraries/maui-iap/android/openiap/build.gradle.kts b/libraries/maui-iap/android/openiap/build.gradle.kts index 1237aeb76..2841ace62 100644 --- a/libraries/maui-iap/android/openiap/build.gradle.kts +++ b/libraries/maui-iap/android/openiap/build.gradle.kts @@ -1,3 +1,4 @@ +import java.util.Locale import groovy.json.JsonSlurper import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -65,32 +66,22 @@ val googleMinSdk = readGoogleAndroidInt("minSdk") val mauiAndroidMinSdk = readMauiAndroidMinSdk() val googleCoreVersion = readGoogleDependencyVersion("androidx.core:core") val googleCoroutinesVersion = readGoogleVariable("coroutinesVersion") -val horizonEnabled = providers.gradleProperty("horizonEnabled").orNull?.toBooleanStrictOrNull() ?: false -val fireOsEnabled = providers.gradleProperty("fireOsEnabled").orNull?.toBooleanStrictOrNull() ?: false -if (horizonEnabled && fireOsEnabled) { - error("maui-iap Android: horizonEnabled and fireOsEnabled cannot both be true") +// One facade AAR serves every store: it ships compiled against Play, and CI +// also compiles it against Horizon and Amazon with -PopeniapStore. +for (legacy in listOf("openIapAndroidStore", "OpenIapAndroidStore")) { + if (providers.gradleProperty(legacy).isPresent) { + error("'$legacy' was replaced by -PopeniapStore=; remove the legacy flag.") + } } - -fun normalizeOpenIapStore(value: String?): String = - when (value?.lowercase()) { - null, "", "play", "google", "gms", "googleplay", "google-play" -> "play" - "horizon", "meta", "quest" -> "horizon" - "amazon", "fire", "fireos", "fire-os" -> "amazon" - else -> error("maui-iap Android: unsupported openIapAndroidStore '$value'") - } - -val requestedOpenIapStore = providers.gradleProperty("openIapAndroidStore").orNull - ?: providers.gradleProperty("OpenIapAndroidStore").orNull -val openIapAndroidStore = when { - fireOsEnabled -> "amazon" - horizonEnabled -> "horizon" - else -> normalizeOpenIapStore(requestedOpenIapStore) -} -val openIapGoogleArtifact = when (openIapAndroidStore) { - "amazon" -> "openiap-google-amazon" - "horizon" -> "openiap-google-horizon" - else -> "openiap-google" +val requestedOpenIapStore = providers.gradleProperty("openiapStore").orNull?.trim()?.lowercase(Locale.ROOT) +val openIapStore = when (requestedOpenIapStore) { + null -> "play" + "play", "google", "gplay", "googleplay", "google-play", "gms" -> "play" + "horizon", "meta", "quest" -> "horizon" + "amazon", "fire", "fireos", "fire-os" -> "amazon" + else -> error("Unsupported -PopeniapStore='$requestedOpenIapStore'. Use play, horizon, or amazon (default: play).") } +val openIapGoogleArtifact = if (openIapStore == "play") "openiap-google" else "openiap-google-$openIapStore" android { namespace = "dev.hyo.openiap.maui" @@ -98,7 +89,7 @@ android { defaultConfig { minSdk = maxOf(googleMinSdk, mauiAndroidMinSdk) - missingDimensionStrategy("platform", openIapAndroidStore) + missingDimensionStrategy("platform", openIapStore) } buildTypes { diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj b/libraries/maui-iap/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj index efa859293..3106b1782 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj @@ -50,6 +50,13 @@ + + + $(MSBuildThisFileDirectory)..\..\..\..\packages\google\openiap\build\outputs\aar\ + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + __MAUI_DEFAULT_SCENE_CONFIGURATION__ + UISceneDelegateClassName + SceneDelegate + + + + CFBundleDisplayName OpenIap MAUI Example diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/MacCatalyst/SceneDelegate.cs b/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/MacCatalyst/SceneDelegate.cs new file mode 100644 index 000000000..62a2caaf8 --- /dev/null +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/MacCatalyst/SceneDelegate.cs @@ -0,0 +1,11 @@ +using Foundation; + +namespace OpenIap.Maui.Example; + +// Mac Catalyst 27 enforces the same scene-lifecycle requirement as iOS 27. +// MAUI ships MauiUISceneDelegate, but the linker drops it unless a registered +// subclass names it, so Info.plist points at this one. +[Register("SceneDelegate")] +public class SceneDelegate : MauiUISceneDelegate +{ +} diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/iOS/Info.plist b/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/iOS/Info.plist index 4907efd7a..1871f6d9b 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/iOS/Info.plist +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/iOS/Info.plist @@ -2,6 +2,24 @@ + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + __MAUI_DEFAULT_SCENE_CONFIGURATION__ + UISceneDelegateClassName + SceneDelegate + + + + CFBundleDisplayName OpenIap MAUI Example LSApplicationQueriesSchemes diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/iOS/SceneDelegate.cs b/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/iOS/SceneDelegate.cs new file mode 100644 index 000000000..fba9ce298 --- /dev/null +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Platforms/iOS/SceneDelegate.cs @@ -0,0 +1,11 @@ +using Foundation; + +namespace OpenIap.Maui.Example; + +// iOS 27 terminates an app built with the iOS 27 SDK that never adopts the +// scene lifecycle. MAUI ships MauiUISceneDelegate, but the linker drops it +// unless a registered subclass names it, so Info.plist points at this one. +[Register("SceneDelegate")] +public class SceneDelegate : MauiUISceneDelegate +{ +} diff --git a/libraries/maui-iap/example/README.md b/libraries/maui-iap/example/README.md index c7645d3fa..717720cb4 100644 --- a/libraries/maui-iap/example/README.md +++ b/libraries/maui-iap/example/README.md @@ -40,7 +40,8 @@ cd libraries/maui-iap/example/OpenIap.Maui.Example # iOS Simulator dotnet build -t:Run -f net10.0-ios -# Android (real device or emulator) +# Android (real device or emulator). Build the Google AARs first: see +# "Example app" in ../README.md. A Debug build links the device's store. adb uninstall dev.hyo.martie || true dotnet build -t:Run -f net10.0-android diff --git a/libraries/maui-iap/scripts/verify-store-selection.sh b/libraries/maui-iap/scripts/verify-store-selection.sh new file mode 100755 index 000000000..d34b012bb --- /dev/null +++ b/libraries/maui-iap/scripts/verify-store-selection.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Regression suite for the MAUI store selection (buildTransitive/OpenIap.Maui.targets), +# using the resolver suite's fake adb and Java dependency verification per store. +# Needs the three store AARs, the MAUI facade AAR, an Android SDK and the +# maui-android workload. +set -euo pipefail + +maui_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +example="$maui_root/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj" + +fake_sdk=$(mktemp -d) +trap 'rm -rf "$fake_sdk"' EXIT +cp "$maui_root/../../packages/google/compatibility/store-resolver/fake-adb" "$fake_sdk/adb" +chmod +x "$fake_sdk/adb" + +dotnet restore "$example" -p:TargetFrameworks=net10.0-android --nologo >/dev/null + +passed=0 +failed=0 + +# run +# is "store:maven-artifact,...", or "fail:". +run() { + local name="$1" expected="$2" target="$3" + shift 3 + local output status=0 actual + output=$(dotnet msbuild "$example" -nologo -p:TargetFramework=net10.0-android \ + "-t:$target" -getProperty:OpenIapLinkedStore -getItem:AndroidMavenLibrary "$@" 2>&1) || status=$? + if [[ "$expected" == fail:* ]]; then + if [[ $status -ne 0 && "$output" == *"${expected#fail:}"* ]]; then + actual="$expected" + else + actual="exit=$status ${output//$'\n'/ }" + fi + elif [[ $status -ne 0 ]]; then + actual="exit=$status ${output//$'\n'/ }" + else + actual=$(printf '%s' "$output" | python3 -c ' +import json, sys +raw = sys.stdin.read() +data = json.loads(raw[raw.index("{"):]) +maven = sorted(i["Identity"].split(":")[1] for i in data["Items"].get("AndroidMavenLibrary", [])) +print(data["Properties"]["OpenIapLinkedStore"] + ":" + ",".join(maven))') + fi + if [[ "$actual" == "$expected" ]]; then + printf ' ok %-42s %s\n' "$name" "$expected" + passed=$((passed + 1)) + else + printf ' FAIL %-42s expected %s, got %s\n' "$name" "$expected" "$actual" + failed=$((failed + 1)) + fi +} + +with_device() { + local serial="$1" features="$2" manufacturer="$3" + export FAKE_ADB_DEVICES="$serial" + export "FAKE_ADB_${serial}_FEATURES=$features" + export "FAKE_ADB_${serial}_MANUFACTURER=$manufacturer" +} + +play="play:billing" +horizon="horizon:core-kotlin,horizon-billing-compatibility,iap-kotlin,user-age-category-kotlin" +amazon="amazon:amazon-appstore-sdk" +link=_OpenIapLinkAndroidStore +adb="-p:AdbToolPath=$fake_sdk/" +unset ANDROID_SERIAL FAKE_ADB_DEVICES || true + +echo "MAUI store selection suite" + +echo "explicit" +run "OpenIapStore=play" "$play" $link -p:OpenIapStore=play +run "alias quest" "$horizon" $link -p:OpenIapStore=quest +run "alias fire-os" "$amazon" $link -p:OpenIapStore=fire-os +run "the OpenIapAndroidStore alias" "$horizon" $link -p:OpenIapAndroidStore=meta +run "OpenIapStore wins over the alias" "$amazon" $link -p:OpenIapStore=amazon -p:OpenIapAndroidStore=horizon +run "a value that names no store fails" "fail:OpenIapStore='bogus' is not a store" $link -p:OpenIapStore=bogus +run "and names the alias when it was set" "fail:OpenIapAndroidStore='bogus' is not a store" $link -p:OpenIapAndroidStore=bogus + +echo "connected device" +with_device QUEST1 "feature:oculus.hardware.standalone_vr" Oculus +run "a Quest links Horizon" "$horizon" $link "$adb" +run "a release build ignores it" "$play" $link "$adb" -p:Configuration=Release +run "a pin outranks it" "$amazon" $link "$adb" -p:OpenIapStore=amazon +with_device FIRE1 "feature:amazon.hardware.fire_tv" Amazon +run "a Fire device links Amazon" "$amazon" $link "$adb" +with_device FIRE2 "" Amazon +run "Amazon without the TV feature" "$amazon" $link "$adb" +with_device GHOST1 "" "" +run "a device that stops answering" "$play" $link "$adb" +with_device PIXEL1 "feature:android.hardware.nfc" Google +run "anything else is Play" "$play" $link "$adb" +export FAKE_ADB_DEVICES="QUEST1 PIXEL1" +run "two devices select nothing" "$play" $link "$adb" +run "AdbTarget picks one of them" "$horizon" $link "$adb" "-p:AdbTarget=-s QUEST1" +ANDROID_SERIAL=QUEST1 run "so does ANDROID_SERIAL" "$horizon" $link "$adb" +ANDROID_SERIAL=ABSENT run "an unattached ANDROID_SERIAL is Play" "$play" $link "$adb" +unset FAKE_ADB_DEVICES +run "no adb at all" "$play" $link -p:AdbToolPath=/nonexistent/ + +echo "dependency verification" +run "Play's Maven SDKs verify" "$play" _CategorizeAndroidLibraries -p:OpenIapStore=play +run "Horizon's Maven SDKs verify" "$horizon" _CategorizeAndroidLibraries -p:OpenIapStore=horizon +run "Amazon's Maven SDKs verify" "$amazon" _CategorizeAndroidLibraries -p:OpenIapStore=amazon + +# --apk also builds the whole example per store and checks what its APK links: +# manifest merge, D8 and packaging are past the stage the cases above reach. +if [[ "${1:-}" == "--apk" ]]; then + echo "full builds" + dexdump="" + for sdk in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk" "$HOME/Android/Sdk"; do + [[ -n "$sdk" ]] || continue + dexdump=$(ls "$sdk"/build-tools/*/dexdump 2>/dev/null | sort -V | tail -1 || true) + [[ -n "$dexdump" ]] && break + done + if [[ -z "$dexdump" ]]; then + echo "No dexdump found; set ANDROID_HOME to an Android SDK with build-tools." >&2 + exit 1 + fi + apk="$maui_root/example/OpenIap.Maui.Example/bin/Debug/net10.0-android/dev.hyo.martie-Signed.apk" + for store in play horizon amazon; do + rm -f "$apk" + dotnet build "$example" -p:TargetFrameworks=net10.0-android -p:OpenIapStore=$store --nologo >/dev/null 2>&1 || true + if [[ ! -f "$apk" ]]; then + printf ' FAIL %-42s no APK\n' "$store APK" + failed=$((failed + 1)) + continue + fi + dex_dir=$(mktemp -d) + (cd "$dex_dir" && unzip -q -o "$apk" 'classes*.dex') + classes=$(for dex in "$dex_dir"/classes*.dex; do "$dexdump" "$dex" | grep "Class descriptor"; done) + rm -rf "$dex_dir" + linked="" + grep -q "Lcom/android/billingclient/" <<<"$classes" && linked+="play," + grep -q "Lcom/meta/horizon/" <<<"$classes" && linked+="horizon," + grep -q "Lcom/amazon/device/iap/" <<<"$classes" && linked+="amazon," + if [[ "$linked" == "$store," ]]; then + printf ' ok %-42s %s\n' "$store APK links only its store" "$store" + passed=$((passed + 1)) + else + printf ' FAIL %-42s expected %s, got %s\n' "$store APK links only its store" "$store" "${linked%,}" + failed=$((failed + 1)) + fi + done +fi + +echo +echo "MAUI store selection: $passed passed, $failed failed" +[[ $failed -eq 0 ]] diff --git a/libraries/maui-iap/src/Directory.Build.props b/libraries/maui-iap/src/Directory.Build.props index 40ade5a75..b9ab6312b 100644 --- a/libraries/maui-iap/src/Directory.Build.props +++ b/libraries/maui-iap/src/Directory.Build.props @@ -1,19 +1,19 @@ - + - 9.1.0 - 3.0.9 - 2.0.0 - 0.2.2 1.11.0.1 2.14.0 - 9.1.0.1 + 118.10.0.2 + 118.10.0.2 + 121.3.0.10 + 118.4.1.2 + 4.1.1.1 2.14.0.1 1.13.0.1 1.6.0.1 - 1.8.9.3 - 1.8.9.4 + 1.9.0 + 1.9.0 2.11.0.1 1.5.0.1 2.4.0.1 diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj b/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj index 60420c683..b610e14c7 100644 --- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj +++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj @@ -31,28 +31,16 @@ false - - play - amazon - horizon - play - ..\..\..\..\packages\google\openiap\build\outputs\aar\openiap-$(OpenIapGoogleAarFlavor)-release.aar - - - + - - + - @@ -60,35 +48,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj index 22998c855..c19b63641 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj +++ b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj @@ -64,20 +64,25 @@ - - play - amazon - horizon - play - ..\..\..\..\packages\google\openiap\build\outputs\aar\openiap-$(OpenIapGoogleAarFlavor)-release.aar - - - + + + + + + + + + + + + + <_OpenIapGoogleAarOutputs>..\..\..\..\packages\google\openiap\build\outputs\aar\ + + + + + + + + + + + @@ -142,7 +152,6 @@ - diff --git a/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props new file mode 100644 index 000000000..22d9f3525 --- /dev/null +++ b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props @@ -0,0 +1,10 @@ + + + + <_OpenIapMauiPropsImported>true + 9.1.0 + 3.0.9 + 2.0.0 + 0.2.2 + + diff --git a/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets new file mode 100644 index 000000000..e1d294fc1 --- /dev/null +++ b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets @@ -0,0 +1,134 @@ + + + + + + + + <_OpenIapStoreInput>$(OpenIapAndroidStore) + <_OpenIapStoreInputName>OpenIapAndroidStore + <_OpenIapStoreInput Condition="'$(OpenIapStore.Trim())' != ''">$(OpenIapStore) + <_OpenIapStoreInputName Condition="'$(OpenIapStore.Trim())' != ''">OpenIapStore + $(_OpenIapStoreInput.Trim().ToLowerInvariant()) + auto + play + horizon + amazon + $(MSBuildThisFileDirectory)..\android\ + + + + + + <_OpenIapAdbExe>$(AdbToolExe) + <_OpenIapAdbExe Condition="'$(_OpenIapAdbExe)' == '' and $([MSBuild]::IsOSPlatform('Windows'))">adb.exe + <_OpenIapAdbExe Condition="'$(_OpenIapAdbExe)' == ''">adb + <_OpenIapAdbDir>$(AdbToolPath) + <_OpenIapAdbDir Condition="'$(_OpenIapAdbDir)' == '' and '$(_AndroidSdkDirectory)' != ''">$([System.IO.Path]::Combine('$(_AndroidSdkDirectory)', 'platform-tools')) + <_OpenIapAdb>$([System.IO.Path]::Combine('$(_OpenIapAdbDir)', '$(_OpenIapAdbExe)')) + + <_OpenIapRequestedSerial>$(ANDROID_SERIAL.Trim()) + <_OpenIapRequestedSerial Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(AdbTarget)', '-s\s+\S+'))">$([System.Text.RegularExpressions.Regex]::Replace('$(AdbTarget)', '^.*-s\s+(\S+).*$', '$1')) + + + + + + + <_OpenIapAdbDevice Include="$([System.Text.RegularExpressions.Regex]::Replace('%(_OpenIapAdbLine.Identity)', '\s+device\s*$', ''))" + Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('%(_OpenIapAdbLine.Identity)', '^\S+\s+device\s*$'))" /> + + + <_OpenIapAdbText>@(_OpenIapAdbLine) + + <_OpenIapAdbAnswered Condition="$(_OpenIapAdbText.Contains('List of devices attached'))">true + <_OpenIapDevices>;@(_OpenIapAdbDevice); + <_OpenIapSerial Condition="'$(_OpenIapRequestedSerial)' != '' and $(_OpenIapDevices.Contains(';$(_OpenIapRequestedSerial);'))">$(_OpenIapRequestedSerial) + <_OpenIapSerial Condition="'$(_OpenIapRequestedSerial)' == '' and '@(_OpenIapAdbDevice->Count())' == '1'">@(_OpenIapAdbDevice) + <_OpenIapProbeReason Condition="'$(_OpenIapAdbAnswered)' != 'true'">adb did not answer + <_OpenIapProbeReason Condition="'$(_OpenIapAdbAnswered)' == 'true' and '$(_OpenIapSerial)' == ''">no single connected device + + + + + + + + + + + <_OpenIapManufacturer>$(_OpenIapManufacturer.Trim()) + + <_OpenIapProbeReason Condition="'$(_OpenIapFeatures.Trim())' == '' and '$(_OpenIapManufacturer)' == ''">$(_OpenIapSerial) stopped responding + <_OpenIapDeviceStore Condition="'$(_OpenIapProbeReason)' == ''">play + <_OpenIapDeviceStore Condition="'$(_OpenIapDeviceStore)' == 'play' and ($(_OpenIapFeatures.Contains('feature:amazon.hardware.fire_tv')) or '$(_OpenIapManufacturer.ToLowerInvariant())' == 'amazon')">amazon + <_OpenIapDeviceStore Condition="'$(_OpenIapDeviceStore)' != '' and ($(_OpenIapFeatures.Contains('feature:horizonos.software.horizon_os')) or $(_OpenIapFeatures.Contains('feature:oculus.hardware.standalone_vr')))">horizon + <_OpenIapManufacturer Condition="'$(_OpenIapManufacturer)' == ''">unknown + <_OpenIapProbeReason Condition="'$(_OpenIapDeviceStore)' != ''">device $(_OpenIapSerial), manufacturer $(_OpenIapManufacturer) + + + + + + + $(OpenIapStorePin) + <_OpenIapSource>explicit + <_OpenIapReason>$(_OpenIapStoreInputName)=$(_OpenIapStoreInput.Trim()) + + + play + <_OpenIapSource>default + <_OpenIapReason>$(Configuration) build + <_OpenIapReason Condition="'$(_OpenIapProbeReason)' != ''">$(_OpenIapProbeReason) + $(_OpenIapDeviceStore) + <_OpenIapSource Condition="'$(_OpenIapDeviceStore)' != ''">device + + + <_OpenIapGoogleAar>$([System.IO.Path]::GetFullPath('$(OpenIapGoogleAarDirectory)openiap-$(OpenIapLinkedStore)-release.aar')) + + + + + + + + <_PropertyCacheItems Include="OpenIapLinkedStore=$(OpenIapLinkedStore)" /> + + + + + + + + + + + + + + + + + + + diff --git a/libraries/react-native-iap/android/build.gradle b/libraries/react-native-iap/android/build.gradle index 3cb97a800..097406ca0 100644 --- a/libraries/react-native-iap/android/build.gradle +++ b/libraries/react-native-iap/android/build.gradle @@ -112,13 +112,9 @@ def getExtOrIntegerDefault(name) { return getExtOrDefault(name).toString().toInteger() } -// Read store flags from gradle.properties, default to play -def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false -def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false -if (horizonEnabled && fireOsEnabled) { - throw new GradleException("react-native-iap: horizonEnabled and fireOsEnabled cannot both be true") -} -def openiapFlavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') +// Store selection: explicit property > task flavor > connected debug device > play. +apply from: project.file('openiap-store.gradle') +def openiapFlavor = openIapResolveStore('react-native-iap').store def resolveOpenIapGoogleBuildFile() { def candidates = [ @@ -253,7 +249,7 @@ dependencies { } // Google Play Services - if (!fireOsEnabled && !horizonEnabled) { + if (openiapFlavor == 'play') { implementation "com.google.android.gms:play-services-base:$playServicesBaseVersion" } @@ -265,9 +261,9 @@ dependencies { def localGoogleProject = findProject(':openiap') if (localGoogleProject != null) { implementation project(':openiap') - } else if (fireOsEnabled) { + } else if (openiapFlavor == 'amazon') { implementation "io.github.hyochan.openiap:openiap-google-amazon:${googleVersionString}" - } else if (horizonEnabled) { + } else if (openiapFlavor == 'horizon') { implementation "io.github.hyochan.openiap:openiap-google-horizon:${googleVersionString}" } else { implementation "io.github.hyochan.openiap:openiap-google:${googleVersionString}" diff --git a/libraries/react-native-iap/android/openiap-store.gradle b/libraries/react-native-iap/android/openiap-store.gradle new file mode 120000 index 000000000..6ae291887 --- /dev/null +++ b/libraries/react-native-iap/android/openiap-store.gradle @@ -0,0 +1 @@ +../../../packages/google/gradle/openiap-store.gradle \ No newline at end of file diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 5798d80ab..feabdcb66 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -63,10 +63,7 @@ import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject -/** - * Custom exception for OpenIAP errors that only includes the error JSON without stack traces. - * This ensures clean error messages are passed to JavaScript without Java/Kotlin stack traces. - */ +/** Carries only the OpenIAP error JSON, so JavaScript never sees a Java/Kotlin stack trace. */ class OpenIapException(private val errorJson: String, cause: Throwable? = null) : Exception(cause) { override val message: String get() = errorJson @@ -211,19 +208,13 @@ class HybridRnIap : HybridRnIapSpec() { private val purchaseUpdatedListeners = TokenizedListenerRegistry<(NitroPurchase) -> Unit>() private val purchaseErrorListeners = mutableListOf<(NitroPurchaseResult) -> Unit>() - // Pending purchase events buffered while ZERO bridge listeners are attached - // (GitHub issue #166). Mirrors expo-iap's ExpoIapHelper.emitOrQueue bounded - // queue (MAX_BUFFERED_EVENTS = 200, drop-oldest on overflow); expo buffers - // purchase errors the same way, so both channels queue here. Covers: - // 1. events fired during initConnection before JS listeners attach - // (e.g. the already-owned recovery republish), and - // 2. events fired while all screens are unmounted, flushed on remount. - // Queued events are flushed FIFO using a listener snapshot per batch. A - // listener added during a flush receives later arrivals, but not backlog - // that predates its registration. - // Like expo (which clears its queue when the connection lifecycle ends), - // endConnection clears these queues; they survive plain unmount/remount - // because useIAP keeps the connection alive across screens. + // Purchase updates and errors queue here while no bridge listener is attached + // (#166), like expo-iap's ExpoIapHelper.emitOrQueue: events fired during + // initConnection before JS attaches (e.g. the already-owned recovery + // republish) or while every screen is unmounted. The next registration + // flushes them FIFO from a listener snapshot, so a listener added mid-flush + // gets only later events. endConnection clears the queues; unmount/remount + // keeps them, because useIAP keeps the connection open across screens. private val pendingPurchaseUpdates = PendingEventBuffer(MAX_PENDING_EVENTS) { RnIapLog.warn("pendingPurchaseUpdates overflow; dropping oldest") } @@ -239,7 +230,6 @@ class HybridRnIap : HybridRnIapSpec() { private var isInitialized = false private val connectionLifecycleQueue = ConnectionLifecycleQueue() - // Connection methods // Variant wrapper helpers shared by the generated Nitrogen bindings. private fun String?.wrapVariant(): Variant_NullType_String? = this?.let { Variant_NullType_String.Second(it) } private fun Double?.wrapVariant(): Variant_NullType_Double? = this?.let { Variant_NullType_Double.Second(it) } @@ -259,13 +249,14 @@ class HybridRnIap : HybridRnIapSpec() { private fun Variant_NullType_Double?.unwrapDouble(): Double? = (this as? Variant_NullType_Double.Second)?.value private fun Variant_NullType_Boolean?.unwrapBool(): Boolean? = (this as? Variant_NullType_Boolean.Second)?.value + // Connection methods override fun initConnection(config: Variant_NullType_InitConnectionConfig?): Promise { val configValue = (config as? Variant_NullType_InitConnectionConfig.Second)?.value val performInit: suspend () -> Boolean = initOperation@{ RnIapLog.payload("initConnection", configValue) - // CRITICAL: Set Activity BEFORE calling initConnection - // Horizon SDK needs Activity to initialize OVRPlatform with proper returnComponent + // Set the Activity before initConnection: Horizon's OVRPlatform init needs it + // for the right returnComponent. // https://github.com/meta-quest/Meta-Spatial-SDK-Samples/issues/82#issuecomment-3452577530 try { withContext(Dispatchers.Main) { @@ -412,9 +403,7 @@ class HybridRnIap : HybridRnIapSpec() { } try { - // Convert Nitro config to OpenIAP config - // Note: enableBillingProgramAndroid is passed to OpenIapInitConnectionConfig - // which handles enabling the billing program internally + // OpenIapInitConnectionConfig enables the billing program itself. val openIapConfig = configValue?.let { OpenIapInitConnectionConfig( enableBillingProgramAndroid = configValue.enableBillingProgramAndroid?.let { program -> @@ -484,10 +473,8 @@ class HybridRnIap : HybridRnIapSpec() { cleanup = { productTypeBySku.clear() isInitialized = false - // Native listener sets persist; clear only bridge callbacks. - // Pending event queues are cleared with them: like expo-iap - // (whose buffer resets when the connection lifecycle ends), - // buffered events do not survive an explicit endConnection. + // Native listener sets persist; clear only bridge callbacks and + // the pending event queues. synchronized(purchaseUpdatedListeners) { purchaseUpdatedListeners.clear() pendingPurchaseUpdates.clear() @@ -570,7 +557,6 @@ class HybridRnIap : HybridRnIapSpec() { } } - // Purchase methods // Purchase methods (Unified) override fun requestPurchase(request: NitroPurchaseRequest): Promise { return Promise.async { @@ -812,8 +798,8 @@ class HybridRnIap : HybridRnIapSpec() { "getAvailablePurchases.native", mapOf("type" to typeEnum.rawValue, "includeSuspended" to includeSuspended) ) - // Note: getAvailableItems doesn't accept PurchaseOptions - // includeSuspended only applies when fetching all types + // getAvailableItems takes no PurchaseOptions, so includeSuspended + // applies only when fetching all types. openIap.getAvailableItems(typeEnum) } else { RnIapLog.payload("getAvailablePurchases.native", mapOf("type" to "all", "includeSuspended" to includeSuspended)) @@ -1144,12 +1130,7 @@ class HybridRnIap : HybridRnIapSpec() { // Helper methods - /** - * Send purchase update event to listeners. - * With zero listeners attached the event is buffered (bounded, drop-oldest) - * instead of dropped, and flushed FIFO when a listener registers. - * Events delivered to at least one listener are never queued. - */ + /** Deliver a purchase update, or queue it while no listener is attached. */ private fun sendPurchaseUpdate(purchase: NitroPurchase) { RnIapLog.result( "sendPurchaseUpdate", @@ -1165,11 +1146,7 @@ class HybridRnIap : HybridRnIapSpec() { snapshot.forEach { it(purchase) } } - /** - * Send purchase error event to listeners. - * Mirrors sendPurchaseUpdate: buffered (bounded, drop-oldest) while zero - * listeners are attached, flushed FIFO when a listener registers. - */ + /** Deliver a purchase error, or queue it while no listener is attached. */ private fun sendPurchaseError(error: NitroPurchaseResult) { RnIapLog.result( "sendPurchaseError", @@ -1505,8 +1482,7 @@ class HybridRnIap : HybridRnIapSpec() { override fun clearTransactionIOS(): Promise { return Promise.async { - // This is an iOS-only feature for clearing unfinished transactions - // On Android, we don't need to do anything + // iOS-only (clears unfinished transactions); nothing to do on Android. } } @@ -1518,7 +1494,7 @@ class HybridRnIap : HybridRnIapSpec() { } } - // Updated signature to follow spec: returns updated subscriptions + // Returns the updated subscriptions, per the spec. override fun showManageSubscriptionsIOS(): Promise> { return Promise.async { // Not supported on Android. Return empty list for iOS-only API. diff --git a/libraries/react-native-iap/example/App.kepler.tsx b/libraries/react-native-iap/example/App.kepler.tsx index d0e0feac8..591e9d261 100644 --- a/libraries/react-native-iap/example/App.kepler.tsx +++ b/libraries/react-native-iap/example/App.kepler.tsx @@ -27,8 +27,10 @@ LogBox.ignoreLogs([ '[RN-IAP] Error getting active subscriptions:', ]); -(global as any).RN_IAP_DEV_MODE = true; -(global as any).RN_IAP_SUPPRESS_NATIVE_ALERTS = true; +Object.assign(globalThis, { + RN_IAP_DEV_MODE: true, + RN_IAP_SUPPRESS_NATIVE_ALERTS: true, +}); type RouteName = | 'Home' diff --git a/libraries/react-native-iap/example/App.tsx b/libraries/react-native-iap/example/App.tsx index 5e2398913..9929d7f15 100644 --- a/libraries/react-native-iap/example/App.tsx +++ b/libraries/react-native-iap/example/App.tsx @@ -4,7 +4,7 @@ import AppNavigator from './navigation'; import {DataModalProvider} from './src/contexts/DataModalContext'; // Enable debug logging for library development only -(global as any).RN_IAP_DEV_MODE = true; +Object.assign(globalThis, {RN_IAP_DEV_MODE: true}); function App(): React.JSX.Element { return ( diff --git a/libraries/react-native-iap/example/__tests__/RnIap.test.tsx b/libraries/react-native-iap/example/__tests__/RnIap.test.tsx index 8d1e48b2d..dc81c4c8b 100644 --- a/libraries/react-native-iap/example/__tests__/RnIap.test.tsx +++ b/libraries/react-native-iap/example/__tests__/RnIap.test.tsx @@ -185,11 +185,11 @@ describe('RnIap Complete Test Suite', () => { describe('Android-specific APIs', () => { beforeEach(() => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); }); afterEach(() => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); }); it('should export acknowledgePurchaseAndroid', () => { diff --git a/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx b/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx index b7c355e83..223ebcc66 100644 --- a/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx @@ -25,11 +25,11 @@ describe('AlternativeBilling Screen', () => { }); afterEach(() => { - (Platform as any).OS = originalPlatform; + Object.assign(Platform, {OS: originalPlatform}); }); it('renders Amazon Vega as unsupported for alternative billing', async () => { - (Platform as any).OS = 'kepler'; + Object.assign(Platform, {OS: 'kepler'}); const {getByText} = await render(); diff --git a/libraries/react-native-iap/example/__tests__/screens/AvailablePurchases.test.tsx b/libraries/react-native-iap/example/__tests__/screens/AvailablePurchases.test.tsx index 2983b6f10..8bd8e2026 100644 --- a/libraries/react-native-iap/example/__tests__/screens/AvailablePurchases.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/AvailablePurchases.test.tsx @@ -116,7 +116,7 @@ describe('AvailablePurchases Screen', () => { it('shows Vega guidance instead of opening unsupported subscription management deep links', async () => { const originalPlatform = Platform.OS; - (Platform as any).OS = 'kepler'; + Object.assign(Platform, {OS: 'kepler'}); try { const {getByText} = await renderWithProviders(); @@ -128,7 +128,7 @@ describe('AvailablePurchases Screen', () => { ).toBeTruthy(); expect(RNIap.deepLinkToSubscriptions).not.toHaveBeenCalled(); } finally { - (Platform as any).OS = originalPlatform; + Object.assign(Platform, {OS: originalPlatform}); } }); diff --git a/libraries/react-native-iap/example/__tests__/screens/Home.test.tsx b/libraries/react-native-iap/example/__tests__/screens/Home.test.tsx index f7702a3ad..0040f7e3c 100644 --- a/libraries/react-native-iap/example/__tests__/screens/Home.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/Home.test.tsx @@ -17,7 +17,7 @@ describe('Home Screen', () => { it('renders header with correct title and subtitle', async () => { const {getByText} = await render( - , + , ); expect(getByText('React Native IAP')).toBeTruthy(); @@ -26,7 +26,7 @@ describe('Home Screen', () => { it('renders all menu items', async () => { const {getByText} = await render( - , + , ); expect(getByText('All Products')).toBeTruthy(); @@ -54,7 +54,7 @@ describe('Home Screen', () => { it('navigates to AllProducts when All Products menu item is pressed', async () => { const {getByText} = await render( - , + , ); const allProductsButton = getByText('All Products').parent?.parent; @@ -67,7 +67,7 @@ describe('Home Screen', () => { it('navigates to PurchaseFlow when Purchase Flow menu item is pressed', async () => { const {getByText} = await render( - , + , ); const purchaseFlowButton = getByText('Purchase Flow').parent?.parent; @@ -80,7 +80,7 @@ describe('Home Screen', () => { it('navigates to SubscriptionFlow when Subscription Flow menu item is pressed', async () => { const {getByText} = await render( - , + , ); const subscriptionFlowButton = @@ -94,7 +94,7 @@ describe('Home Screen', () => { it('navigates to AvailablePurchases when Available Purchases menu item is pressed', async () => { const {getByText} = await render( - , + , ); const availablePurchasesButton = getByText('Available Purchases').parent @@ -108,7 +108,7 @@ describe('Home Screen', () => { it('navigates to OfferCode when Offer Code menu item is pressed', async () => { const {getByText} = await render( - , + , ); const offerCodeButton = getByText('Offer Code').parent?.parent; @@ -121,7 +121,7 @@ describe('Home Screen', () => { it('navigates to AlternativeBilling when Alternative Billing menu item is pressed', async () => { const {getByText} = await render( - , + , ); const alternativeBillingButton = getByText('Alternative Billing').parent @@ -135,7 +135,7 @@ describe('Home Screen', () => { it('renders footer text', async () => { const {getByText} = await render( - , + , ); expect( diff --git a/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx b/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx index 28045027a..6322ab98a 100644 --- a/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx @@ -147,7 +147,7 @@ describe('OfferCode Screen', () => { }); it('shows Vega unsupported guidance without calling the redemption API', async () => { - (Platform as any).OS = 'kepler'; + Object.assign(Platform, {OS: 'kepler'}); const {getByText} = await render(); await fireEvent.press(getByText('Amazon Vega IAP')); diff --git a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx index e86ee8e20..75c8d22b6 100644 --- a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx @@ -887,10 +887,12 @@ describe('PurchaseFlow Screen', () => { it('does not consume while IAPKit verification is pending', async () => { Platform.OS = 'android'; - const purchase = { + const purchase: Purchase = { id: 'transaction-race-1', + isAutoRenewing: false, productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'google-token-race-1', + quantity: 1, store: 'google', transactionDate: Date.now(), purchaseState: 'purchased', @@ -927,7 +929,7 @@ describe('PurchaseFlow Screen', () => { expect(verifyPurchaseWithProvider).toHaveBeenCalledTimes(1); mockIapState({ - availablePurchases: [purchase as any], + availablePurchases: [purchase], finishTransaction, verifyPurchaseWithProvider, }); diff --git a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx index 76c9331dc..b4d09fa34 100644 --- a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx @@ -5,6 +5,7 @@ import * as RNIap from 'react-native-iap'; import {SUBSCRIPTION_PRODUCT_IDS} from '../../src/utils/constants'; import type { MutationFinishTransactionArgs, + ProductSubscriptionAndroid, Purchase, VerifyPurchaseWithProviderProps, VerifyPurchaseWithProviderResult, @@ -23,18 +24,22 @@ jest.mock( const requestPurchaseMock = RNIap.requestPurchase as jest.Mock; const deepLinkToSubscriptionsMock = RNIap.deepLinkToSubscriptions as jest.Mock; -const sampleSubscription = { - type: 'subs' as const, +// The extra token-like field exercises the console redaction. +const sampleSubscription: ProductSubscriptionAndroid & { + offerTokenAndroid: string; +} = { + type: 'subs', id: 'dev.hyo.martie.premium', title: 'Premium Subscription', description: 'Access all premium features', displayPrice: '$9.99/month', price: 9.99, currency: 'USD', - platform: 'android' as const, + platform: 'android', nameAndroid: 'Premium Subscription', + subscriptionOffers: [], offerTokenAndroid: 'offer-secret', -} as any; // Mock object, actual types vary by platform +}; describe('SubscriptionFlow Screen', () => { let onPurchaseSuccess: ((purchase: any) => Promise | void) | undefined; @@ -161,7 +166,10 @@ describe('SubscriptionFlow Screen', () => { activeSubscriptions: [ { productId: 'dev.hyo.martie.premium', - } as any, + transactionId: 'trans-1', + transactionDate: Date.now(), + isActive: true, + }, ], }); @@ -818,11 +826,12 @@ describe('SubscriptionFlow Screen', () => { it('does not acknowledge while IAPKit verification is pending', async () => { Platform.OS = 'android'; - const purchase = { + const purchase: Purchase = { id: 'transaction-sub-race-1', - platform: 'android', + isAutoRenewing: true, productId: 'dev.hyo.martie.premium', purchaseToken: 'google-sub-token-race-1', + quantity: 1, store: 'google', transactionDate: Date.now(), purchaseState: 'purchased', @@ -859,7 +868,7 @@ describe('SubscriptionFlow Screen', () => { expect(verifyPurchaseWithProvider).toHaveBeenCalledTimes(1); mockIapState({ - availablePurchases: [purchase as any], + availablePurchases: [purchase], finishTransaction, verifyPurchaseWithProvider, }); @@ -1127,7 +1136,7 @@ describe('SubscriptionFlow Screen', () => { transactionId: 'trans-1', transactionDate: Date.now(), isActive: true, - } as any, + }, ], subscriptions: [ { @@ -1218,7 +1227,10 @@ describe('SubscriptionFlow Screen', () => { activeSubscriptions: [ { productId: 'dev.hyo.martie.premium', - } as any, + transactionId: 'trans-1', + transactionDate: Date.now(), + isActive: true, + }, ], }); @@ -1336,23 +1348,27 @@ describe('SubscriptionFlow Screen', () => { transactionDate: Date.now(), isActive: true, purchaseToken: 'mock-purchase-token-123', - } as any, + }, ], subscriptions: [ { ...sampleSubscription, subscriptionOffers: [ { + id: 'premium', basePlanIdAndroid: 'premium', offerTokenAndroid: 'offer-token-monthly', displayPrice: '$9.99', - type: 'subs', + price: 9.99, + type: 'promotional', }, { + id: 'premium-year', basePlanIdAndroid: 'premium-year', offerTokenAndroid: 'offer-token-yearly', displayPrice: '$99.99', - type: 'subs', + price: 99.99, + type: 'promotional', }, ], }, diff --git a/libraries/react-native-iap/example/android/app/build.gradle b/libraries/react-native-iap/example/android/app/build.gradle index c3bdd7932..fbbd67e1a 100644 --- a/libraries/react-native-iap/example/android/app/build.gradle +++ b/libraries/react-native-iap/example/android/app/build.gradle @@ -1,6 +1,8 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" +// Same resolver as react-native-iap, so the app and the library link one store. +apply from: new File(project(':react-native-iap').projectDir, 'openiap-store.gradle') /** * This is the configuration block to customize your React Native Android app. @@ -85,13 +87,7 @@ android { versionCode = 1 versionName = "1.0" - def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false - def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false - if (horizonEnabled && fireOsEnabled) { - throw new GradleException("react-native-iap example: horizonEnabled and fireOsEnabled cannot both be true") - } - def flavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') - missingDimensionStrategy "platform", flavor + missingDimensionStrategy "platform", openIapResolveStore('app').store } compileOptions { diff --git a/libraries/react-native-iap/example/android/gradle.properties b/libraries/react-native-iap/example/android/gradle.properties index cd5142216..dd5c83f83 100644 --- a/libraries/react-native-iap/example/android/gradle.properties +++ b/libraries/react-native-iap/example/android/gradle.properties @@ -43,12 +43,8 @@ hermesEnabled=true # Note: Only works with ReactActivity and should not be used with custom Activity. edgeToEdgeEnabled=false -# Enable Horizon OS support for Meta Quest (set to true to use openiap-google-horizon) -# When true, uses Meta's Platform SDK instead of Google Play Billing -# Default: false (uses Google Play Billing) -# horizonEnabled=true - -# Enable Fire OS support for Amazon distribution -# Default: false (uses Google Play Billing unless horizonEnabled=true) -# Do not enable with horizonEnabled in the same build. -# fireOsEnabled=true +# Store selection is automatic: a task flavor (assembleHorizonRelease) or, for +# debug builds, the connected Quest or Fire device (ANDROID_SERIAL picks among +# several) selects the store; play otherwise. Pin one here or with +# -PopeniapStore= when a build must not look at a device. +# openiapStore=horizon diff --git a/libraries/react-native-iap/example/ios/Podfile b/libraries/react-native-iap/example/ios/Podfile index 5371756f0..727da5b53 100644 --- a/libraries/react-native-iap/example/ios/Podfile +++ b/libraries/react-native-iap/example/ios/Podfile @@ -58,10 +58,9 @@ target 'example' do pod 'openiap', openiap_apple_version end - # Host-app XCTest bundle. `inherit! :search_paths` lets the tests `@testable - # import NitroIap` (and reach OpenIAP + Nitro modules) without duplicating - # pod installations. The TEST_HOST runs inside the example app, so all - # linked frameworks are already loaded at test time. + # Host-app XCTest bundle: `inherit! :search_paths` lets the tests + # `@testable import NitroIap` (and reach OpenIAP + Nitro) without duplicate pod + # installs; the TEST_HOST example app already loads those frameworks. target 'exampleTests' do inherit! :search_paths end diff --git a/libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift b/libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift index 1491ea73b..58c6700ac 100644 --- a/libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift +++ b/libraries/react-native-iap/example/ios/exampleTests/SubscriptionBillingIssueReconnectTests.swift @@ -2,15 +2,13 @@ import XCTest import OpenIAP @testable import NitroIap -/// Reconnect regression coverage for the iOS subscriptionBillingIssue listener. +/// Reconnect regression coverage for the iOS subscriptionBillingIssue listener: +/// native subscriptions exist only for an initialized connection, and a +/// completed `endConnection()` leaves no stale subscription or callback. /// -/// Covers listener attachment and cleanup across a disconnect/reconnect cycle. -/// Native subscriptions must only exist for an initialized connection, and a -/// completed `endConnection()` must leave no stale subscription or callback. -/// -/// Reflection note: the sub + listeners are `private` in HybridRnIap so `@testable` -/// alone cannot read them. `Mirror` ignores Swift access control at runtime, so we -/// use it to assert the post-conditions without widening the production API surface. +/// The subscription and listeners are `private` in HybridRnIap, out of reach for +/// `@testable`; `Mirror` ignores access control at runtime, so the tests read +/// them without widening the production API. @available(iOS 15.0, macOS 14.0, tvOS 15.0, watchOS 8.0, *) final class SubscriptionBillingIssueReconnectTests: XCTestCase { diff --git a/libraries/react-native-iap/example/screens/AllProducts.tsx b/libraries/react-native-iap/example/screens/AllProducts.tsx index 90d5695db..9e9a59bae 100644 --- a/libraries/react-native-iap/example/screens/AllProducts.tsx +++ b/libraries/react-native-iap/example/screens/AllProducts.tsx @@ -17,49 +17,19 @@ import { NON_CONSUMABLE_PRODUCT_IDS, } from '../src/utils/constants'; import {getErrorMessage} from '../src/utils/errorUtils'; -import type { - Product, - ProductSubscription, -} from 'react-native-iap'; +import type {Product, ProductSubscription} from 'react-native-iap'; /** - * All Products Example - Show All Products and Subscriptions + * All Products Example: fetches in-app products and subscriptions separately + * and shows both in one list, as the API returns them. * - * Demonstrates fetching all products (both in-app and subscriptions): - * - Fetches in-app products and subscriptions separately - * - Displays products and subscriptions as they come from the API - * - Single view for all product types - * - * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - * 🎯 TypeScript Discriminated Union Type Narrowing Examples - * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - * - * This file demonstrates real-world usage of discriminated union type narrowing - * with OpenIAP gql 1.2.4+ types. See the following functions for examples: - * - * Example 1 (Line ~134): getProductTypeLabel() - * - Shows basic type narrowing using the 'type' discriminator - * - Narrows Product | ProductSubscription -> ProductSubscription - * - * Example 2 (Line ~91): handleShowDetails() - * - Demonstrates combining 'platform' and 'type' discriminators - * - Shows how to narrow to specific types like ProductSubscriptionIOS - * - Includes console.log examples showing type-safe field access - * - * Key discriminator fields: - * - `type`: 'in-app' | 'subs' - Distinguishes products from subscriptions - * - `platform`: 'ios' | 'android' - Distinguishes platform-specific types - * - * Type hierarchy: - * - Product = ProductIOS | ProductAndroid (type: 'in-app') - * - ProductSubscription = ProductSubscriptionIOS | ProductSubscriptionAndroid (type: 'subs') - * - * Benefits: - * ✅ Type-safe access to platform-specific fields and standardized offers - * ✅ Compile-time errors prevent accessing non-existent fields - * ✅ Better IDE autocomplete and IntelliSense - * ✅ Runtime safety - no accessing undefined fields - * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + * Also shows discriminated-union narrowing on the OpenIAP gql 1.2.4+ types: + * - `type` ('in-app' | 'subs') separates Product (ProductIOS | ProductAndroid) + * from ProductSubscription (ProductSubscriptionIOS | ProductSubscriptionAndroid). + * - `platform` ('ios' | 'android') narrows to the platform-specific type. + * getProductTypeLabel() narrows on `type`; handleShowDetails() combines both to + * reach types like ProductSubscriptionIOS. The compiler then checks access to + * platform-specific fields and standardized offers. */ function AllProducts() { @@ -123,10 +93,8 @@ function AllProducts() { }, [products, subscriptions]); /** - * 🎯 Type Narrowing Example 2: Platform + Type narrowing - * - * This demonstrates combining both 'platform' and 'type' discriminators - * to narrow down to a specific type (e.g., ProductSubscriptionIOS). + * Type narrowing example 2: combine `platform` and `type` to reach a specific + * type (e.g., ProductSubscriptionIOS). */ const handleShowDetails = (product: Product | ProductSubscription) => { // Log type narrowing examples @@ -152,10 +120,7 @@ function AllProducts() { } else if (product.platform === 'android') { // ✅ Narrowed to: ProductSubscriptionAndroid console.log('- Android Subscription detected'); - console.log( - '- Offers:', - product.subscriptionOffers.length, - ); + console.log('- Offers:', product.subscriptionOffers.length); } } else { // ✅ Narrowed to: Product (in-app) @@ -176,12 +141,7 @@ function AllProducts() { setModalVisible(true); }; - /** - * 🎯 Type Narrowing Example 1: Using 'type' discriminator - * - * This demonstrates how TypeScript narrows the union type - * Product | ProductSubscription using the 'type' field. - */ + /** Type narrowing example 1: `type` narrows Product | ProductSubscription. */ const getProductTypeLabel = (product: Product | ProductSubscription) => { // Type narrowing using 'type' discriminator if (product.type === 'subs') { diff --git a/libraries/react-native-iap/example/screens/AlternativeBilling.tsx b/libraries/react-native-iap/example/screens/AlternativeBilling.tsx index 33551b65e..dda1fa0c4 100644 --- a/libraries/react-native-iap/example/screens/AlternativeBilling.tsx +++ b/libraries/react-native-iap/example/screens/AlternativeBilling.tsx @@ -16,9 +16,7 @@ import { initConnection, endConnection, presentExternalPurchaseLinkIOS, - // Billing Programs API (Android 8.2.0+) - // Note: enableBillingProgramAndroid must be set in InitConnectionConfig - // For User Choice Billing, use 'user-choice-billing' in enableBillingProgramAndroid + // Billing Programs API (Android 8.2.0+); enable the program in InitConnectionConfig isBillingProgramAvailableAndroid, createBillingProgramReportingDetailsAndroid, launchExternalLinkAndroid, diff --git a/libraries/react-native-iap/example/screens/AvailablePurchases.tsx b/libraries/react-native-iap/example/screens/AvailablePurchases.tsx index a94d661f4..196b92480 100644 --- a/libraries/react-native-iap/example/screens/AvailablePurchases.tsx +++ b/libraries/react-native-iap/example/screens/AvailablePurchases.tsx @@ -13,7 +13,6 @@ import type {PurchaseError} from 'react-native-iap'; import {useIAP, deepLinkToSubscriptions} from 'react-native-iap'; import {useDataModal} from '../src/contexts/DataModalContext'; -// Define subscription IDs at component level like in the working example const subscriptionIds = [ 'dev.hyo.martie.premium', // Same as subscription-flow ]; @@ -31,7 +30,6 @@ export default function AvailablePurchases() { // Use global modal context const {showData} = useDataModal(); - // Use the useIAP hook like subscription-flow does const { connected, subscriptions, @@ -49,7 +47,6 @@ export default function AvailablePurchases() { store: purchase.store, }); - // Finish transaction like in subscription-flow await finishTransaction({ purchase, isConsumable: false, @@ -66,7 +63,6 @@ export default function AvailablePurchases() { }, }); - // Check subscription status like subscription-flow does const checkSubscriptionStatus = useCallback(async () => { if (!connected || isCheckingStatus) { console.log( @@ -113,7 +109,7 @@ export default function AvailablePurchases() { } }; - // Load products and available purchases when connected - follow subscription-flow pattern + // Load products and available purchases once connected useEffect(() => { if (connected) { console.log( @@ -140,7 +136,7 @@ export default function AvailablePurchases() { } }, [connected, fetchProducts, getAvailablePurchases]); - // Check subscription status separately like subscription-flow does + // Check subscription status separately useEffect(() => { if (connected) { // Use a timeout to avoid rapid consecutive calls @@ -332,7 +328,7 @@ export default function AvailablePurchases() { )} - {/* iOS-specific fields with new IOS naming convention */} + {/* iOS-specific fields */} {Platform.OS === 'ios' && 'expirationDateIOS' in purchase && purchase.expirationDateIOS && ( diff --git a/libraries/react-native-iap/example/screens/Home.tsx b/libraries/react-native-iap/example/screens/Home.tsx index 26e3d3bcf..865e99e13 100644 --- a/libraries/react-native-iap/example/screens/Home.tsx +++ b/libraries/react-native-iap/example/screens/Home.tsx @@ -15,7 +15,7 @@ type HomeScreenNavigationProp = NativeStackNavigationProp< >; type Props = { - navigation: HomeScreenNavigationProp; + navigation: Pick; }; const Home: React.FC = ({navigation}) => { diff --git a/libraries/react-native-iap/example/screens/OfferCode.tsx b/libraries/react-native-iap/example/screens/OfferCode.tsx index a3cab8893..1ee197633 100644 --- a/libraries/react-native-iap/example/screens/OfferCode.tsx +++ b/libraries/react-native-iap/example/screens/OfferCode.tsx @@ -11,12 +11,7 @@ import { } from 'react-native'; import {openRedeemOfferCode, useIAP} from 'react-native-iap'; -/** - * Offer Code Redemption Example - * - * This example demonstrates how to implement offer code redemption - * functionality for both iOS and Android platforms. - */ +/** Offer code redemption example for iOS and Android. */ const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; diff --git a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx index f81fb1f06..57f22e708 100644 --- a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx +++ b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx @@ -81,14 +81,8 @@ type PurchaseFlowProps = { }; /** - * Purchase Flow Example - In-App Products - * - * Demonstrates useIAP hook approach for in-app products: - * - Uses useIAP hook for purchase management - * - Handles purchase callbacks with proper types - * - No manual promise handling required - * - Clean success/error pattern through hooks - * - Focused on one-time purchases (products) + * Purchase Flow Example: one-time in-app products through the useIAP hook, + * with typed success/error callbacks instead of manual promise handling. */ function PurchaseFlow({ @@ -555,44 +549,21 @@ function PurchaseFlow({ } /** - * ============================================================================ - * Purchase Flow Container - * ============================================================================ - * - * This component demonstrates the complete IAP purchase flow with 6 key steps: - * - * 1. INIT CONNECTION - * - useIAP hook automatically handles connection via initConnection() - * - `connected` state indicates when store is ready - * - * 2. SUBSCRIBE TO EVENTS - * - useIAP internally subscribes to purchase events - * - onPurchaseSuccess: Called when purchase completes successfully - * - onPurchaseError: Called when purchase fails or is cancelled - * - * 3. REQUEST PURCHASE (3 options) - * Option A: iOS-specific request with quantity - * Option B: Android-specific request with SKU array - * Option C: Cross-platform using `request` object (recommended) - * - * 4. VERIFY PURCHASE - * - Local (Device): Direct Apple/Google verification on the device - * - Local (IAPKit): Verify through a locally running IAPKit server - * - IAPKit: Verify through kit.openiap.dev - * - Skip verification: For testing only (not recommended for production) - * - * 5. GRANT ENTITLEMENT - * - Update your backend/database with purchase info - * - Unlock content or features for the user - * - (Handled by your app's business logic) - * - * 6. FINISH TRANSACTION - * - Call finishTransaction() to acknowledge the purchase - * - For consumables: isConsumable: true (allows re-purchase) - * - For non-consumables: isConsumable: false - * - CRITICAL: Always finish transactions to prevent issues + * Purchase Flow Container: the full in-app purchase flow in six steps. * - * ============================================================================ + * 1. Init connection: useIAP calls initConnection(); `connected` turns true + * when the store is ready. + * 2. Subscribe to events: useIAP subscribes internally. onPurchaseSuccess fires + * when a purchase completes, onPurchaseError when it fails or is cancelled. + * 3. Request purchase: iOS-only with quantity, Android-only with a SKU array, + * or cross-platform with the `request` object (recommended). + * 4. Verify: on the device (Local), through a locally running IAPKit server, + * through kit.openiap.dev (IAPKit), or skip it (testing only; not + * recommended for production). + * 5. Grant entitlement: your app's business logic updates your backend and + * unlocks the content. + * 6. Finish: always call finishTransaction(). isConsumable: true lets a + * consumable be bought again; use false for non-consumables. */ function PurchaseFlowContainer() { // ────────────────────────────────────────────────────────────────────────── @@ -834,19 +805,14 @@ function PurchaseFlowContainer() { // ────────────────────────────────────────────────────────────────────── // Step 5: GRANT ENTITLEMENT // ────────────────────────────────────────────────────────────────────── - // Production integration point: - // - Save purchase record to database - // - Unlock premium features for user - // - Update user's subscription status - // Example: await yourBackend.grantEntitlement(purchase); + // In production, save the purchase, update the user's status, and unlock + // the features on your backend, e.g. await yourBackend.grantEntitlement(purchase); // ────────────────────────────────────────────────────────────────────── // Step 6: FINISH TRANSACTION // ────────────────────────────────────────────────────────────────────── - // CRITICAL: Always finish transactions! - // - Consumables: Set isConsumable: true to allow re-purchase - // - Non-consumables: Set isConsumable: false - // - Failing to finish will cause issues on next app launch + // Always finish, or the transaction causes issues on the next app launch. + // isConsumable: true lets a consumable be bought again; false otherwise. try { await finishTransaction({ purchase, @@ -1008,16 +974,16 @@ function PurchaseFlowContainer() { // Three options for requesting purchases: // // Option A - iOS only: - // requestPurchase({ request: { ios: { sku, quantity } }, type: 'in-app' }) + // requestPurchase({ request: { apple: { sku, quantity } }, type: 'in-app' }) // // Option B - Android only: - // requestPurchase({ request: { android: { skus: [sku] } }, type: 'in-app' }) + // requestPurchase({ request: { google: { skus: [sku] } }, type: 'in-app' }) // // Option C - Cross-platform (recommended): // requestPurchase({ // request: { - // ios: { sku, quantity: 1 }, - // android: { skus: [sku] } + // apple: { sku, quantity: 1 }, + // google: { skus: [sku] } // }, // type: 'in-app' // }) diff --git a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx index 7d2ebdf88..f6b54a198 100644 --- a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx +++ b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx @@ -111,12 +111,9 @@ function formatPurchaseDate(transactionDate: Purchase['transactionDate']) { return Number.isNaN(date.getTime()) ? 'N/A' : date.toLocaleDateString(); } -// Extended type for ActiveSubscription with additional fields that may be present -// but are not officially part of the ActiveSubscription type definition. -// These fields are either: -// - Detected/computed locally (basePlanId, _detectedBasePlanId) -// - Available in the underlying Purchase but not mapped to ActiveSubscription (isUpgradedIOS) -// - Platform-specific fields (purchaseTokenAndroid) +// ActiveSubscription plus fields outside its type: computed locally (basePlanId, +// _detectedBasePlanId), on the Purchase but not mapped (isUpgradedIOS), or +// platform-specific (purchaseTokenAndroid). type ExtendedActiveSubscription = ActiveSubscription & { basePlanId?: string; // Android: detected from subscription offers purchaseTokenAndroid?: string; // Android: purchase token @@ -156,8 +153,7 @@ const PlanChangeControls = React.memo(function PlanChangeControls({ let activeSub: ActiveSubscription | undefined = undefined; if (Platform.OS === 'ios') { - // On iOS, find the most recent subscription (in case both exist during transition) - // Sort by transaction date to get the most recent one + // On iOS, take the most recent subscription; both can exist mid-transition. const sortedSubs = [...premiumSubs].sort((a, b) => { const dateA = a.transactionDate ?? 0; const dateB = b.transactionDate ?? 0; @@ -166,8 +162,7 @@ const PlanChangeControls = React.memo(function PlanChangeControls({ activeSub = sortedSubs[0]; - // Check for the most recent purchase to determine actual plan - // First, check if both products exist (transition state) + // Check which products exist; both means a transition. const hasYearly = premiumSubs.some( (s) => s.productId === 'dev.hyo.martie.premium_year', ); @@ -267,19 +262,13 @@ const PlanChangeControls = React.memo(function PlanChangeControls({ }); /** - * Subscription Flow Example - Subscription Products + * Subscription Flow Example: recurring subscriptions through the useIAP hook, + * with typed success/error callbacks instead of manual promise handling. * - * Demonstrates useIAP hook approach for subscriptions: - * - Uses useIAP hook for subscription management - * - Handles subscription callbacks with proper types - * - No manual promise handling required - * - Clean success/error pattern through hooks - * - Focused on recurring subscriptions - * - * New subscription status checking API: - * - getActiveSubscriptions() - gets all active subscriptions automatically - * - getActiveSubscriptions(['id1', 'id2']) - gets specific subscriptions - * - activeSubscriptions state - automatically updated subscription list + * Status checks: + * - getActiveSubscriptions() - all active subscriptions + * - getActiveSubscriptions(['id1', 'id2']) - only those subscriptions + * - activeSubscriptions state - updated automatically */ type SubscriptionFlowProps = { @@ -356,7 +345,7 @@ function SubscriptionFlow({ changeType: 'upgrade' | 'downgrade' | 'yearly' | 'monthly', currentBasePlanId: string, ) => { - // iOS doesn't use this function anymore as upgrade/downgrade is handled by App Store + // Unused on iOS: the App Store handles upgrades and downgrades. if (Platform.OS === 'ios') { return; } @@ -896,9 +885,8 @@ function SubscriptionFlow({ ? '📅 Yearly Plan' : '📆 Monthly Plan'; } - // Method 2: Check localStorage for last purchased plan + // Method 2: fall back to the last purchased plan in state else { - // Try to get from state const storedPlan = lastPurchasedPlan; if (storedPlan === 'premium-year') { @@ -920,8 +908,6 @@ function SubscriptionFlow({ // We'll use this detectedBasePlanId in the button section below } - // No need for separate handling since we already check both products above - return ( )} - {/* 🆕 NEW: renewalInfoIOS showcase */} + {/* renewalInfoIOS showcase */} {Platform.OS === 'ios' && sub.renewalInfoIOS && ( @@ -1104,7 +1090,7 @@ function SubscriptionFlow({ )} - {/* 🆕 NEW: Renewal offer type */} + {/* Renewal offer type */} {sub.renewalInfoIOS.renewalOfferType && ( @@ -1116,7 +1102,7 @@ function SubscriptionFlow({ )} - {/* 🆕 NEW: Renewal offer ID */} + {/* Renewal offer ID */} {sub.renewalInfoIOS.renewalOfferId && ( 🆕 Offer ID: @@ -1126,7 +1112,7 @@ function SubscriptionFlow({ )} - {/* 🆕 NEW: JSON Representation availability */} + {/* JSON Representation availability */} {sub.renewalInfoIOS.jsonRepresentation && ( @@ -1511,82 +1497,42 @@ function SubscriptionFlow({ } /** - * ============================================================================ - * Subscription Flow Container - * ============================================================================ - * - * This component demonstrates the complete subscription lifecycle with proper - * handling of platform-specific differences between iOS and Android. - * - * ┌─────────────────────────────────────────────────────────────────────────┐ - * │ PLATFORM COMPARISON - Subscription Data Availability │ - * ├─────────────────────────────┬─────────────┬─────────────┬──────────────┤ - * │ Information │ iOS Client │ Android │ Server │ - * ├─────────────────────────────┼─────────────┼─────────────┼──────────────┤ - * │ Auto-renew status │ ✅ willAutoRenew │ ✅ isAutoRenewing │ ✅ │ - * │ Next renewal product │ ✅ autoRenewPreference │ ❌ │ ✅ │ - * │ Pending upgrade/downgrade │ ✅ pendingUpgradeProductId │ ❌ │ ✅ │ - * │ Expiration reason │ ✅ expirationReason │ ❌ │ ✅ │ - * │ Grace period status │ ✅ gracePeriodExpirationDate │ ❌ │ ✅ │ - * │ Billing retry status │ ✅ isInBillingRetry │ ❌ │ ✅ │ - * │ Renewal date │ ✅ renewalDate │ ❌ │ ✅ │ - * │ Detailed subscription state │ ✅ │ ❌ │ ✅ │ - * └─────────────────────────────┴─────────────┴─────────────┴──────────────┘ - * - * 💡 Key Takeaway: iOS provides rich subscription data client-side via - * renewalInfoIOS, while Android requires server-side calls for details. - * - * ============================================================================ - * SUBSCRIPTION LIFECYCLE FLOWS - * ============================================================================ - * - * 1. ON APP LAUNCH - * ├─ iOS: initConnection → getAvailablePurchases → check transactionState - * │ → validate with server → update entitlements → finishTransaction - * └─ Android: initConnection → getAvailablePurchases → for pending purchases - * → validate → acknowledge → grant entitlements + * Subscription Flow Container: the full subscription lifecycle and where iOS + * and Android differ. * - * 2. NEW PURCHASE FLOW - * ├─ iOS: requestPurchase → purchaseUpdatedListener receives PurchaseIOS - * │ → check transactionState (purchased/pending/failed/deferred) - * │ → validate → deliver → finishTransaction - * └─ Android: requestPurchase → purchaseUpdatedListener receives PurchaseAndroid - * → check purchaseState (0=pending, 1=purchased, 2=failed) - * → validate → acknowledge → grant entitlements + * Client-side data: iOS exposes renewalInfoIOS with willAutoRenew, + * autoRenewPreference (next renewal product), pendingUpgradeProductId, + * expirationReason, gracePeriodExpirationDate, isInBillingRetry, renewalDate, + * and detailed state. The Android client only has auto-renew status + * (isAutoRenewing); everything else needs a server call. The server has it all. * - * 3. CHECKING SUBSCRIPTION STATUS - * ├─ iOS: getActiveSubscriptions → check renewalInfoIOS: - * │ • willAutoRenew = false → show renewal prompt - * │ • isInBillingRetry = true → show payment issue - * │ • pendingUpgradeProductId → show pending change - * └─ Android: getActiveSubscriptions → check isActive - * (detailed info requires server-side API call) + * Lifecycle: + * 1. App launch + * - iOS: initConnection → getAvailablePurchases → check purchaseState → + * validate with server → update entitlements → finishTransaction + * - Android: initConnection → getAvailablePurchases → for pending purchases, + * validate → acknowledge → grant entitlements + * 2. New purchase: requestPurchase → purchaseUpdatedListener receives the purchase + * - Both platforms: check purchaseState ('purchased', 'pending' or + * 'unknown') → validate → deliver → finishTransaction + * 3. Status: getActiveSubscriptions + * - iOS: check renewalInfoIOS. willAutoRenew = false → renewal prompt; + * isInBillingRetry = true → payment issue; pendingUpgradeProductId → + * pending change + * - Android: check isActive; details need a server-side API call + * 4. Cancellation: willAutoRenew = false (iOS) or isAutoRenewing = false + * (Android); access continues until expiry (iOS: expirationDate). + * 5. Expiration: getActiveSubscriptions returns empty → revoke access → offer + * re-subscribe. + * 6. Restore: getAvailablePurchases (iOS: StoreKit fetches the Apple ID + * history; Android: returns cached purchases) → validate each → grant + * access → finishTransaction (iOS only). * - * 4. DETECTING CANCELLATIONS - * ├─ iOS: willAutoRenew = false (user still has access until expirationDate) - * └─ Android: isAutoRenewing = false (access until expiry) - * - * 5. HANDLING EXPIRATION - * └─ getActiveSubscriptions returns empty → revoke access → show re-subscribe - * - * 6. RESTORING PURCHASES - * ├─ iOS: getAvailablePurchases → StoreKit fetches from Apple ID history - * │ → validate each → grant access → finishTransaction - * └─ Android: getAvailablePurchases → returns cached purchases - * → validate each → grant access - * - * ============================================================================ - * WHEN TO VALIDATE (Server-side recommended) - * ============================================================================ - * • After purchase — Verify the purchase is legitimate - * • On restore — Check current status (active/cancelled/refunded/expired) - * • Periodically for active subscriptions — Detect refunds and cancellations - * • On app launch — Sync subscription state with server - * - * ⚠️ REFUND EDGE CASE: Refunds bypass client entirely. Server-side validation - * with App Store Server Notifications V2 (iOS) or RTDN (Android) is required. - * - * ============================================================================ + * Validate server-side after purchase (legitimacy), on restore (current + * status: active/cancelled/refunded/expired), at launch (sync state), and + * periodically for active subscriptions (refunds, cancellations). Refunds + * bypass the client entirely, so they need App Store Server Notifications V2 + * (iOS) or RTDN (Android). */ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────────── @@ -1770,7 +1716,7 @@ function SubscriptionFlowContainer() { // - 'iapkit-localhost': IAPKit provider through the local server // - 'iapkit': IAPKit provider through the hosted service // - // ⚠️ Server-side validation is recommended for production: + // For production, validate server-side: // - iOS: App Store Server API + App Store Server Notifications V2 // - Android: Google Play Developer API + RTDN const currentVerificationMethod = verificationMethodRef.current; @@ -1897,20 +1843,16 @@ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────── // STEP 4: GRANT ENTITLEMENT // ────────────────────────────────────────────────────────────────────── - // Production integration point: - // - Save subscription record to database - // - Unlock premium features for user - // - Update user's subscription status - // - Handle subscription tiers/levels - // Example: await yourBackend.grantSubscriptionEntitlement(purchase); + // In production, save the subscription, update the user's status, and + // unlock their tier's features on your backend, e.g. + // await yourBackend.grantSubscriptionEntitlement(purchase); // ────────────────────────────────────────────────────────────────────── // STEP 5: FINISH TRANSACTION // ────────────────────────────────────────────────────────────────────── - // CRITICAL: Always finish/acknowledge transactions! - // - iOS: finishTransaction removes from StoreKit queue - // - Android: Acknowledges purchase (required within 3 days) - // - Subscriptions are NOT consumable (isConsumable: false) + // Always finish: iOS removes the transaction from the StoreKit queue; + // Android acknowledges the purchase (required within 3 days). + // Subscriptions are not consumable (isConsumable: false). const isConsumable = false; const finishAndRefreshSubscription = async ( @@ -2037,8 +1979,7 @@ function SubscriptionFlowContainer() { // STEP 2: NEW PURCHASE FLOW - Success Handler // ──────────────────────────────────────────────────────────────────────── // Called when purchaseUpdatedListener receives a successful purchase. - // iOS: Check transactionState (purchased/pending/failed/deferred) - // Android: Check purchaseState (0=pending, 1=purchased, 2=failed) + // Check purchaseState ('purchased', 'pending' or 'unknown') on both platforms. onPurchaseSuccess: dispatchPurchaseSuccess, // ──────────────────────────────────────────────────────────────────────── @@ -2094,8 +2035,7 @@ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────────── // ON APP LAUNCH: Fetch available subscription products // ────────────────────────────────────────────────────────────────────────── - // When the store connection is established, fetch subscription products. - // This populates the subscriptions array for display. + // Once connected, fetch the subscription products shown on screen. useEffect(() => { if (connected) { if (!fetchedProductsOnceRef.current) { @@ -2147,7 +2087,7 @@ function SubscriptionFlowContainer() { } }, [availablePurchases, connected, dispatchPurchaseSuccess]); - // 🔍 LOG: Check discount and promotional offer data + // Log discount and promotional offer data useEffect(() => { if (subscriptions.length > 0) { console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); @@ -2191,21 +2131,15 @@ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────────── // CHECKING SUBSCRIPTION STATUS (Step 3 in lifecycle) // ────────────────────────────────────────────────────────────────────────── - // Periodically verify subscription status to detect: - // - Cancellations (willAutoRenew = false) - // - Billing issues (isInBillingRetry = true) - // - Pending upgrades/downgrades (pendingUpgradeProductId) - // - Grace period status (gracePeriodExpirationDate) - // - // iOS: Rich data available via renewalInfoIOS - // Android: Basic data available; detailed info requires server-side API + // Check periodically for cancellations (willAutoRenew = false), billing + // issues (isInBillingRetry = true), pending upgrades/downgrades + // (pendingUpgradeProductId), and grace periods (gracePeriodExpirationDate). const handleRefreshStatus = useCallback(async () => { if (!connected || isCheckingStatus) return; setIsCheckingStatus(true); try { - // Refresh active subscriptions - this is the key API for status checking - // Returns ActiveSubscription[] with platform-specific renewal info + // The key status API; results carry platform-specific renewal info. const activeSubs = await getActiveSubscriptions(); console.log('\n===== Active Subscriptions Check ====='); console.log('Total subscriptions:', activeSubs.length); @@ -2243,7 +2177,7 @@ function SubscriptionFlowContainer() { console.log(' expirationDateIOS:', sub.expirationDateIOS); console.log(' environmentIOS:', sub.environmentIOS); - // 🔍 Check if renewalInfoIOS exists and log all fields + // Log every renewalInfoIOS field when present if (sub.renewalInfoIOS) { console.log(' ✅ renewalInfoIOS EXISTS:'); console.log(' willAutoRenew:', sub.renewalInfoIOS.willAutoRenew); @@ -2272,7 +2206,7 @@ function SubscriptionFlowContainer() { ' priceIncreaseStatus:', sub.renewalInfoIOS.priceIncreaseStatus, ); - // 🆕 NEW FIELDS - Check if they're coming through correctly + // Confirm the renewal offer fields come through console.log( ' 🆕 renewalOfferType:', sub.renewalInfoIOS.renewalOfferType, @@ -2316,14 +2250,10 @@ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────────── // REQUEST SUBSCRIPTION PURCHASE // ────────────────────────────────────────────────────────────────────────── - // Initiates a subscription purchase. Platform-specific handling: - // - // iOS: Uses sku and optional appAccountToken for user tracking - // StoreKit handles offer eligibility automatically - // - // Android: Requires subscriptionOffers with offerToken - // Each offer represents a base plan (monthly/yearly) or promotional offer - // The offerToken is obtained from subscriptionOffers (cross-platform type) + // iOS: pass the sku, plus an optional appAccountToken to track the user; + // StoreKit handles offer eligibility. + // Android: pass subscriptionOffers, each with the offerToken of a base plan + // (monthly/yearly) or promotional offer from the product's subscriptionOffers. const handleSubscription = useCallback( (itemId: string) => { setIsProcessing(true); @@ -2371,9 +2301,6 @@ function SubscriptionFlowContainer() { [subscriptions], ); - // ────────────────────────────────────────────────────────────────────────── - // RESTORING PURCHASES - // ────────────────────────────────────────────────────────────────────────── // Retry loading subscriptions (useful when products fail to load initially) const handleRetryLoadSubscriptions = useCallback(() => { fetchProducts({ @@ -2389,11 +2316,8 @@ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────────── // MANAGE SUBSCRIPTIONS (Deep link to platform settings) // ────────────────────────────────────────────────────────────────────────── - // Opens the platform's subscription management screen where users can: - // - Cancel subscriptions - // - Change subscription plans (upgrade/downgrade) - // - Update payment methods - // - View subscription history + // Opens the platform's subscription management screen, where users cancel, + // change plans (upgrade/downgrade), update payment methods, and view history. const handleManageSubscriptions = useCallback(async () => { try { await deepLinkToSubscriptions(); diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 248f85437..a8c64dc7b 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -142,8 +142,7 @@ class HybridRnIap: HybridRnIapSpec { let epoch = self.listenerLock.withLock { self.connectionEpoch } do { - // Note: iOS doesn't support alternative billing config parameter - // Config is ignored on iOS platform + // iOS ignores the alternative billing config. self.attachCoreListenersIfNeeded(epoch: epoch) let ok: Bool if let connect { @@ -454,7 +453,7 @@ class HybridRnIap: HybridRnIapSpec { return Promise.async { do { RnIapLog.payload("getActiveSubscriptions", subscriptionIds ?? []) - // Call OpenIAP's native getActiveSubscriptions - includes renewalInfoIOS! + // OpenIAP's native getActiveSubscriptions includes renewalInfoIOS. let subscriptions = try await self.runConnectedOperation { try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds) } @@ -536,7 +535,7 @@ class HybridRnIap: HybridRnIapSpec { func verifyPurchase(params: NitroPurchaseVerificationParams) throws -> Promise { return Promise.async { do { - // Extract SKU from apple options (new platform-specific structure) + // Extract SKU from the apple options guard case .second(let appleOptions) = params.apple, !appleOptions.sku.isEmpty else { throw OpenIapException.make(code: .developerError, message: "Missing required parameter: apple.sku") } @@ -2127,13 +2126,13 @@ class HybridRnIap: HybridRnIapSpec { } } - // MARK: - External Purchase (iOS 16.0+) + // MARK: - External Purchase func canPresentExternalPurchaseNoticeIOS() throws -> Promise { return Promise.async { RnIapLog.payload("canPresentExternalPurchaseNoticeIOS", nil) - if #available(iOS 16.0, *) { + if #available(iOS 17.4, *) { do { let canPresent = try await self.runConnectedOperation { try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS() @@ -2151,9 +2150,8 @@ class HybridRnIap: HybridRnIapSpec { throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } else { - let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase notice requires iOS 16.0 or later") - RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: err) - throw err + RnIapLog.result("canPresentExternalPurchaseNoticeIOS", false) + return false } } } @@ -2162,7 +2160,7 @@ class HybridRnIap: HybridRnIapSpec { return Promise.async { RnIapLog.payload("presentExternalPurchaseNoticeSheetIOS", nil) - if #available(iOS 16.0, *) { + if #available(iOS 17.4, *) { do { let result = try await self.runConnectedOperation { try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS() @@ -2196,7 +2194,7 @@ class HybridRnIap: HybridRnIapSpec { throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } else { - let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase notice requires iOS 16.0 or later") + let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase notice requires iOS 17.4 or later") RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: err) throw err } @@ -2298,8 +2296,7 @@ class HybridRnIap: HybridRnIapSpec { return Promise.async { RnIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", ["noticeType": noticeType.stringValue]) do { - // Convert Nitro enum to OpenIAP enum - // Handle 'unspecified' by defaulting to 'browser' (workaround for Nitro requiring 2+ enum values) + // 'unspecified' exists only for Nitro's 2+ value rule; treat it as 'browser'. let openIapNoticeType: OpenIAP.ExternalPurchaseCustomLinkNoticeTypeIOS if noticeType == .unspecified { RnIapLog.warn("showExternalPurchaseCustomLinkNoticeIOS received 'unspecified' noticeType, defaulting to 'browser'.") diff --git a/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index e1b7546d3..6466f03d2 100644 --- a/libraries/react-native-iap/ios/RnIapHelper.swift +++ b/libraries/react-native-iap/ios/RnIapHelper.swift @@ -79,9 +79,7 @@ enum RnIapHelper { return encoded } - // The currently published native OpenIAP package reports an encoding - // failure as an empty dictionary. Reject that sentinel so a partial batch - // can never be surfaced as a successful purchase query. + // Rejects the same empty-dictionary sentinel as encodeRequired. static func purchasesRequired(_ purchases: [OpenIAP.Purchase]) throws -> [[String: Any]] { try purchases.map { purchase in let encoded = OpenIapSerialization.purchase(purchase) diff --git a/libraries/react-native-iap/package.json b/libraries/react-native-iap/package.json index b49dd8a28..6feb7d884 100644 --- a/libraries/react-native-iap/package.json +++ b/libraries/react-native-iap/package.json @@ -60,7 +60,7 @@ "test:all": "yarn test:library && yarn test:example", "test:ci": "jest --maxWorkers=2 --coverage", "test:ci:example": "yarn workspace rn-iap-example test --coverage", - "verify:consumer-install": "node ../../scripts/verify-npm-consumer-install.mjs --package . --package-name react-native-iap --required openiap-versions.json --required lib/module/index.js --required lib/typescript/src/index.d.ts --required android/build.gradle --required NitroIap.podspec --required nitro.json", + "verify:consumer-install": "node ../../scripts/verify-npm-consumer-install.mjs --package . --package-name react-native-iap --required openiap-versions.json --required lib/module/index.js --required lib/typescript/src/index.d.ts --required android/build.gradle --required android/openiap-store.gradle --required NitroIap.podspec --required nitro.json", "prepare:husky": "husky", "precommit": "lint-staged", "ci:check": "./scripts/ci-check.sh", diff --git a/libraries/react-native-iap/scripts/ci-check.sh b/libraries/react-native-iap/scripts/ci-check.sh index de787f19c..4a76abdb6 100755 --- a/libraries/react-native-iap/scripts/ci-check.sh +++ b/libraries/react-native-iap/scripts/ci-check.sh @@ -1,7 +1,6 @@ #!/bin/bash -# Script to run all CI checks locally before committing -# This helps catch issues before they fail in CI +# Runs the CI checks locally, to catch failures before committing. echo "🚀 Running CI checks locally..." echo "================================" diff --git a/libraries/react-native-iap/src/__tests__/conformance.test.ts b/libraries/react-native-iap/src/__tests__/conformance.test.ts index dda7aaa3b..38a360755 100644 --- a/libraries/react-native-iap/src/__tests__/conformance.test.ts +++ b/libraries/react-native-iap/src/__tests__/conformance.test.ts @@ -2,10 +2,8 @@ /** * react-native-iap's binding into the OpenIAP conformance suite. * - * The Nitro module is replaced with a deterministic fake store, so the real - * SDK wrappers in src/index.ts run against controlled store responses. This is - * what makes purchase, completion, and restoration behaviors testable at all — - * a real purchase cannot happen in CI. + * A deterministic fake store replaces the Nitro module, so the real wrappers in + * src/index.ts run against controlled responses; CI cannot make a real purchase. * * Behavior ids match packages/conformance/src/spec/behaviors.mjs. */ diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts index 7d2081f7b..7dbac2636 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts @@ -54,11 +54,11 @@ describe('hooks/useIAP Android', () => { }); beforeEach(() => { - jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any); - jest.spyOn(IAP, 'getAvailablePurchases').mockResolvedValue([] as any); - jest.spyOn(IAP, 'getActiveSubscriptions').mockResolvedValue([] as any); - jest.spyOn(IAP, 'hasActiveSubscriptions').mockResolvedValue(false as any); - jest.spyOn(IAP, 'finishTransaction').mockResolvedValue(undefined as any); + jest.spyOn(IAP, 'initConnection').mockResolvedValue(true); + jest.spyOn(IAP, 'getAvailablePurchases').mockResolvedValue([]); + jest.spyOn(IAP, 'getActiveSubscriptions').mockResolvedValue([]); + jest.spyOn(IAP, 'hasActiveSubscriptions').mockResolvedValue(false); + jest.spyOn(IAP, 'finishTransaction').mockResolvedValue(undefined); jest.spyOn(IAP, 'purchaseUpdatedListener').mockImplementation(() => { return {remove: jest.fn()}; }); @@ -118,7 +118,7 @@ describe('hooks/useIAP Android', () => { it('registers userChoiceBillingAndroid listener when callback is provided', async () => { const mockUserChoiceBillingListener = jest - .spyOn(IAP, 'userChoiceBillingListenerAndroid' as any) + .spyOn(IAP, 'userChoiceBillingListenerAndroid') .mockImplementation(() => ({remove: jest.fn()})); let api: any; @@ -288,7 +288,7 @@ describe('hooks/useIAP Android', () => { await act(async () => {}); (IAP.initConnection as jest.Mock).mockClear(); - jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(true as any); + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(true); let result: boolean | undefined; await act(async () => { diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts index 927a0014d..01217c136 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts @@ -44,37 +44,77 @@ jest.mock('react-native', () => ({ import * as IAP from '../../index'; import {useIAP} from '../../hooks/useIAP'; import {Platform} from 'react-native'; +import type { + FetchProductsResult, + ProductAndroid, + ProductSubscriptionAndroid, +} from '../../types'; + +const inAppProduct = ( + id: string, + overrides: Partial = {}, +): ProductAndroid => ({ + currency: 'USD', + description: id, + displayPrice: '$1.00', + id, + nameAndroid: id, + platform: 'android', + title: id, + type: 'in-app', + ...overrides, +}); + +const subscriptionProduct = ( + id: string, + overrides: Partial = {}, +): ProductSubscriptionAndroid => ({ + currency: 'USD', + description: id, + displayPrice: '$1.00', + id, + nameAndroid: id, + platform: 'android', + subscriptionOffers: [], + title: id, + type: 'subs', + ...overrides, +}); describe('hooks/useIAP (renderer)', () => { afterEach(() => { jest.clearAllMocks(); - delete (global as any).RN_IAP_DEV_MODE; + Reflect.deleteProperty(globalThis, 'RN_IAP_DEV_MODE'); }); let capturedPurchaseListener: any; - let mockFetchProducts: jest.SpyInstance; - let mockGetAvailablePurchases: jest.SpyInstance; - let mockGetActiveSubscriptions: jest.SpyInstance; - let mockHasActiveSubscriptions: jest.SpyInstance; - let mockSyncIOS: jest.SpyInstance; + let mockFetchProducts: jest.SpiedFunction; + let mockGetAvailablePurchases: jest.SpiedFunction< + typeof IAP.getAvailablePurchases + >; + let mockGetActiveSubscriptions: jest.SpiedFunction< + typeof IAP.getActiveSubscriptions + >; + let mockHasActiveSubscriptions: jest.SpiedFunction< + typeof IAP.hasActiveSubscriptions + >; + let mockSyncIOS: jest.SpiedFunction; beforeEach(() => { capturedPurchaseListener = undefined; - jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any); + jest.spyOn(IAP, 'initConnection').mockResolvedValue(true); mockGetAvailablePurchases = jest .spyOn(IAP, 'getAvailablePurchases') - .mockResolvedValue([] as any); + .mockResolvedValue([]); mockGetActiveSubscriptions = jest .spyOn(IAP, 'getActiveSubscriptions') - .mockResolvedValue([] as any); + .mockResolvedValue([]); mockHasActiveSubscriptions = jest .spyOn(IAP, 'hasActiveSubscriptions') - .mockResolvedValue(false as any); - jest.spyOn(IAP, 'finishTransaction').mockResolvedValue(undefined as any); - mockFetchProducts = jest - .spyOn(IAP, 'fetchProducts') - .mockResolvedValue([] as any); - mockSyncIOS = jest.spyOn(IAP, 'syncIOS').mockResolvedValue(true as any); + .mockResolvedValue(false); + jest.spyOn(IAP, 'finishTransaction').mockResolvedValue(undefined); + mockFetchProducts = jest.spyOn(IAP, 'fetchProducts').mockResolvedValue([]); + mockSyncIOS = jest.spyOn(IAP, 'syncIOS').mockResolvedValue(true); jest.spyOn(IAP, 'purchaseUpdatedListener').mockImplementation((cb: any) => { capturedPurchaseListener = cb; return {remove: jest.fn()}; @@ -155,7 +195,7 @@ describe('hooks/useIAP (renderer)', () => { () => new Promise((resolve) => { resolveActiveSubscriptions = () => resolve([]); - }) as any, + }), ); const onPurchaseSuccess = jest.fn(); @@ -359,11 +399,21 @@ describe('hooks/useIAP (renderer)', () => { }); it('does not log product offer tokens', async () => { - (global as any).RN_IAP_DEV_MODE = true; + Object.assign(globalThis, {RN_IAP_DEV_MODE: true}); const debug = jest.spyOn(console, 'debug').mockImplementation(); mockFetchProducts.mockResolvedValueOnce([ - {id: 'product1', discountOffers: [{offerTokenAndroid: 'secret-token'}]}, - ] as any); + inAppProduct('product1', { + discountOffers: [ + { + currency: 'USD', + displayPrice: '$0.50', + offerTokenAndroid: 'secret-token', + price: 0.5, + type: 'one-time', + }, + ], + }), + ]); let api: any; const Harness = () => { @@ -391,11 +441,11 @@ describe('hooks/useIAP (renderer)', () => { it('refreshes an existing product when it is fetched again', async () => { mockFetchProducts .mockResolvedValueOnce([ - {id: 'product1', type: 'in-app', displayPrice: '$1.00'}, - ] as any) + inAppProduct('product1', {displayPrice: '$1.00'}), + ]) .mockResolvedValueOnce([ - {id: 'product1', type: 'in-app', displayPrice: '$2.00'}, - ] as any); + inAppProduct('product1', {displayPrice: '$2.00'}), + ]); let api: any; const Harness = () => { @@ -424,11 +474,11 @@ describe('hooks/useIAP (renderer)', () => { it('refreshes an existing subscription when it is fetched again', async () => { mockFetchProducts .mockResolvedValueOnce([ - {id: 'subscription1', type: 'subs', displayPrice: '$1.00'}, - ] as any) + subscriptionProduct('subscription1', {displayPrice: '$1.00'}), + ]) .mockResolvedValueOnce([ - {id: 'subscription1', type: 'subs', displayPrice: '$2.00'}, - ] as any); + subscriptionProduct('subscription1', {displayPrice: '$2.00'}), + ]); let api: any; const Harness = () => { @@ -452,13 +502,13 @@ describe('hooks/useIAP (renderer)', () => { it('refreshes products and subscriptions fetched together', async () => { mockFetchProducts .mockResolvedValueOnce([ - {id: 'product1', type: 'in-app', displayPrice: '$1.00'}, - {id: 'subscription1', type: 'subs', displayPrice: '$2.00'}, - ] as any) + inAppProduct('product1', {displayPrice: '$1.00'}), + subscriptionProduct('subscription1', {displayPrice: '$2.00'}), + ]) .mockResolvedValueOnce([ - {id: 'product1', type: 'in-app', displayPrice: '$3.00'}, - {id: 'subscription1', type: 'subs', displayPrice: '$4.00'}, - ] as any); + inAppProduct('product1', {displayPrice: '$3.00'}), + subscriptionProduct('subscription1', {displayPrice: '$4.00'}), + ]); let api: any; const Harness = () => { @@ -489,9 +539,8 @@ describe('hooks/useIAP (renderer)', () => { }); it('ignores an older all-products response for the same SKUs', async () => { - type ProductResult = {id: string; type: string; displayPrice: string}; - let resolveFirst: ((value: ProductResult[]) => void) | undefined; - let resolveSecond: ((value: ProductResult[]) => void) | undefined; + let resolveFirst: ((value: FetchProductsResult) => void) | undefined; + let resolveSecond: ((value: FetchProductsResult) => void) | undefined; mockFetchProducts .mockImplementationOnce( () => @@ -530,16 +579,16 @@ describe('hooks/useIAP (renderer)', () => { await act(async () => { resolveSecond?.([ - {id: 'product1', type: 'in-app', displayPrice: '$3.00'}, - {id: 'subscription1', type: 'subs', displayPrice: '$4.00'}, + inAppProduct('product1', {displayPrice: '$3.00'}), + subscriptionProduct('subscription1', {displayPrice: '$4.00'}), ]); await secondFetch!; }); await act(async () => { resolveFirst?.([ - {id: 'product1', type: 'in-app', displayPrice: '$1.00'}, - {id: 'subscription1', type: 'subs', displayPrice: '$2.00'}, + inAppProduct('product1', {displayPrice: '$1.00'}), + subscriptionProduct('subscription1', {displayPrice: '$2.00'}), ]); await firstFetch!; }); @@ -553,10 +602,8 @@ describe('hooks/useIAP (renderer)', () => { }); it('delegates product fetches while the hook is disconnected', async () => { - jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false as any); - mockFetchProducts.mockResolvedValueOnce([ - {id: 'product1', type: 'in-app'}, - ] as any); + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false); + mockFetchProducts.mockResolvedValueOnce([inAppProduct('product1')]); let api: any; const Harness = () => { @@ -582,10 +629,10 @@ describe('hooks/useIAP (renderer)', () => { it('surfaces the native error when fetching while disconnected', async () => { const originalPlatform = Platform.OS; - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const notConnected = new Error('Billing client not ready'); try { - jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false as any); + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false); mockFetchProducts.mockRejectedValueOnce(notConnected); let api: any; @@ -613,7 +660,7 @@ describe('hooks/useIAP (renderer)', () => { expect(thrown).toBe(notConnected); expect(api.products).toEqual([]); } finally { - (Platform as any).OS = originalPlatform; + Object.assign(Platform, {OS: originalPlatform}); } }); @@ -767,7 +814,7 @@ describe('hooks/useIAP (renderer)', () => { }); it('rejects when restorePurchases syncIOS returns false on iOS', async () => { - mockSyncIOS.mockResolvedValueOnce(false as any); + mockSyncIOS.mockResolvedValueOnce(false); let api: any; const onError = jest.fn(); @@ -981,7 +1028,7 @@ describe('hooks/useIAP (renderer)', () => { // Reset mock to track reconnect call (IAP.initConnection as jest.Mock).mockClear(); - jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any); + jest.spyOn(IAP, 'initConnection').mockResolvedValue(true); let result: boolean | undefined; await act(async () => { @@ -1004,7 +1051,7 @@ describe('hooks/useIAP (renderer)', () => { }); await act(async () => {}); - jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false as any); + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false); let result: boolean | undefined; await act(async () => { @@ -1086,13 +1133,13 @@ describe('hooks/useIAP (renderer)', () => { () => new Promise((_, reject) => { rejectFirst = reject; - }) as any, + }), ) .mockImplementationOnce( () => new Promise((resolve) => { resolveSecond = resolve; - }) as any, + }), ); let firstReconnect: Promise; @@ -1140,13 +1187,13 @@ describe('hooks/useIAP (renderer)', () => { () => new Promise((resolve) => { resolveFirst = resolve; - }) as any, + }), ) .mockImplementationOnce( () => new Promise((resolve) => { resolveSecond = resolve; - }) as any, + }), ); let firstReconnect: Promise; @@ -1192,7 +1239,7 @@ describe('hooks/useIAP (renderer)', () => { await IAP.endConnection(); (IAP.purchaseUpdatedListener as jest.Mock).mockClear(); - jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(true as any); + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(true); await act(async () => { await api.reconnect(); @@ -1224,16 +1271,16 @@ describe('hooks/useIAP (renderer)', () => { const callOrder: string[] = []; jest.spyOn(IAP, 'initConnection').mockImplementation(async () => { callOrder.push('initConnection'); - return true as any; + return true; }); - jest.spyOn(IAP, 'purchaseUpdatedListener').mockImplementation((() => { + jest.spyOn(IAP, 'purchaseUpdatedListener').mockImplementation(() => { callOrder.push('purchaseUpdatedListener'); return {remove: jest.fn()}; - }) as any); - jest.spyOn(IAP, 'purchaseErrorListener').mockImplementation((() => { + }); + jest.spyOn(IAP, 'purchaseErrorListener').mockImplementation(() => { callOrder.push('purchaseErrorListener'); return {remove: jest.fn()}; - }) as any); + }); const Harness = () => { useIAP(); @@ -1260,7 +1307,7 @@ describe('hooks/useIAP (renderer)', () => { () => new Promise((resolve) => { resolveInit = resolve; - }) as any, + }), ); let api: any; @@ -1294,11 +1341,11 @@ describe('hooks/useIAP (renderer)', () => { const purchaseErrorRemove = jest.fn(); jest .spyOn(IAP, 'purchaseUpdatedListener') - .mockImplementation((() => ({remove: purchaseUpdateRemove})) as any); + .mockImplementation(() => ({remove: purchaseUpdateRemove})); jest .spyOn(IAP, 'purchaseErrorListener') - .mockImplementation((() => ({remove: purchaseErrorRemove})) as any); - jest.spyOn(IAP, 'initConnection').mockResolvedValue(false as any); + .mockImplementation(() => ({remove: purchaseErrorRemove})); + jest.spyOn(IAP, 'initConnection').mockResolvedValue(false); let api: any; const Harness = () => { @@ -1322,10 +1369,10 @@ describe('hooks/useIAP (renderer)', () => { const purchaseErrorRemove = jest.fn(); jest .spyOn(IAP, 'purchaseUpdatedListener') - .mockImplementation((() => ({remove: purchaseUpdateRemove})) as any); + .mockImplementation(() => ({remove: purchaseUpdateRemove})); jest .spyOn(IAP, 'purchaseErrorListener') - .mockImplementation((() => ({remove: purchaseErrorRemove})) as any); + .mockImplementation(() => ({remove: purchaseErrorRemove})); jest .spyOn(IAP, 'initConnection') .mockRejectedValue(new Error('init failed')); @@ -1353,11 +1400,11 @@ describe('hooks/useIAP (renderer)', () => { const purchaseUpdateRemove = jest.fn(); jest .spyOn(IAP, 'purchaseUpdatedListener') - .mockImplementation((() => ({remove: purchaseUpdateRemove})) as any); - jest.spyOn(IAP, 'purchaseErrorListener').mockImplementation((() => { + .mockImplementation(() => ({remove: purchaseUpdateRemove})); + jest.spyOn(IAP, 'purchaseErrorListener').mockImplementation(() => { throw new Error('listener attach failed'); - }) as any); - jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any); + }); + jest.spyOn(IAP, 'initConnection').mockResolvedValue(true); const onError = jest.fn(); let api: any; diff --git a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts index 25fa8c01a..c332c5767 100644 --- a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts @@ -1,6 +1,6 @@ import * as IAP from '../index.kepler'; import {ErrorCode} from '../types'; -import type {RequestPurchaseProps} from '../types'; +import type {PurchaseAndroid, RequestPurchaseProps} from '../types'; import {getVegaIapModule} from '../vega'; jest.mock('../hooks/useIAP', () => ({useIAP: jest.fn()})); @@ -53,7 +53,8 @@ describe('Amazon Vega public API', () => { it('rejects removed product type aliases', async () => { await expect( - IAP.fetchProducts({skus: ['coins'], type: 'inapp' as any}), + // @ts-expect-error 'inapp' is a removed alias the runtime must reject. + IAP.fetchProducts({skus: ['coins'], type: 'inapp'}), ).rejects.toThrow(/Unsupported product type/); expect(fetchProductsNative).not.toHaveBeenCalled(); }); @@ -76,7 +77,8 @@ describe('Amazon Vega public API', () => { await expect( IAP.requestPurchase({ request: {google: {skus: ['coins']}}, - type: 'all' as any, + // @ts-expect-error 'all' is query-only; the runtime must reject it for purchases. + type: 'all', }), ).rejects.toMatchObject({ code: ErrorCode.DeveloperError, @@ -221,11 +223,20 @@ describe('Amazon Vega public API', () => { }), ).resolves.toBeNull(); - await expect( - IAP.finishTransaction({purchase: {productId: 'coins'} as any}), - ).rejects.toThrow(/purchaseToken required/); + const purchase: PurchaseAndroid = { + id: 'purchase', + isAutoRenewing: false, + productId: 'coins', + purchaseState: 'purchased', + quantity: 1, + store: 'amazon', + transactionDate: 0, + }; + await expect(IAP.finishTransaction({purchase})).rejects.toThrow( + /purchaseToken required/, + ); await IAP.finishTransaction({ - purchase: {productId: 'coins', purchaseToken: 'receipt'} as any, + purchase: {...purchase, purchaseToken: 'receipt'}, isConsumable: true, }); expect(vegaModule.finishTransaction).toHaveBeenCalledWith({ @@ -292,11 +303,11 @@ describe('Amazon Vega public API', () => { await expect(IAP.presentCodeRedemptionSheetIOS()).resolves.toBeNull(); for (const call of [ - () => IAP.verifyPurchase({} as any), + () => IAP.verifyPurchase({}), () => IAP.syncIOS(), () => IAP.presentExternalPurchaseLinkIOS('https://example.com'), - () => IAP.deepLinkToSubscriptions({} as any), - () => IAP.isBillingProgramAvailableAndroid('external-offer' as any), + () => IAP.deepLinkToSubscriptions({}), + () => IAP.isBillingProgramAvailableAndroid('external-offer'), () => IAP.getBillingChoiceInfoAndroid({}), () => IAP.launchExternalLinkAndroid({ @@ -306,12 +317,14 @@ describe('Amazon Vega public API', () => { linkUri: 'https://example.com', }), () => - IAP.createBillingProgramReportingDetailsAndroid( - 'external-offer' as any, - ), + IAP.createBillingProgramReportingDetailsAndroid({ + program: 'external-offer', + }), () => - IAP.showBillingProgramInformationDialogAndroid('external-offer' as any), - () => IAP.showInAppMessagesAndroid({} as any), + IAP.showBillingProgramInformationDialogAndroid({ + externalTransactionToken: 'token', + }), + () => IAP.showInAppMessagesAndroid({}), ]) { await expect(call()).rejects.toThrow(/not supported on Amazon Vega/); } diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 1615d02c0..bfba966b0 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -84,8 +84,8 @@ jest.mock('react-native-nitro-modules', () => ({ }, })); -// Import after mocks using require to ensure init-time mocks apply cleanly -// (explicit require is used here to avoid dynamic import and to cooperate with jest.resetModules) +// Require after the mocks so init-time mocks apply; require (not a dynamic +// import) also works with jest.resetModules. let IAP: any = require('../index'); describe('Public API (src/index.ts)', () => { @@ -110,7 +110,7 @@ describe('Public API (src/index.ts)', () => { () => purchaseUpdatedToken++, ); // Default to iOS in tests; override per-case - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); // Re-require module to ensure fresh state if needed jest.resetModules(); jest.dontMock('react-native'); @@ -134,8 +134,7 @@ describe('Public API (src/index.ts)', () => { }); describe('platform detection helpers', () => { - // Note: More comprehensive platform detection tests are in platform-detection.test.ts - // which properly resets modules for accurate Platform detection testing + // platform-detection.test.ts covers the rest; it resets modules per case. it('isNitroReady returns true when Nitro is initialized', () => { expect(IAP.isNitroReady()).toBe(true); }); @@ -290,7 +289,7 @@ describe('Public API (src/index.ts)', () => { }); it('removes the Android native listener by token and re-attaches on next subscribe', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const sub1 = IAP.purchaseUpdatedListener(jest.fn()); const sub2 = IAP.purchaseUpdatedListener(jest.fn()); @@ -378,7 +377,7 @@ describe('Public API (src/index.ts)', () => { }); it('promotedProductListenerIOS warns and no-ops on non‑iOS', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const sub = IAP.promotedProductListenerIOS(jest.fn()); expect(typeof sub.remove).toBe('function'); @@ -387,9 +386,7 @@ describe('Public API (src/index.ts)', () => { }); it('promotedProductListenerIOS on iOS converts and forwards product', () => { - (Platform as any).OS = 'ios'; - (Platform as any).isTV = false; - (Platform as any).isMacCatalyst = false; + Object.assign(Platform, {OS: 'ios'}); const nitroProduct = { id: 'sku1', title: 'Title', @@ -515,7 +512,7 @@ describe('Public API (src/index.ts)', () => { }); it('detaches the Android native error listener after the last JS listener is removed', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const sub1 = IAP.purchaseErrorListener(jest.fn()); const sub2 = IAP.purchaseErrorListener(jest.fn()); const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0]; @@ -545,7 +542,7 @@ describe('Public API (src/index.ts)', () => { }); it('passes developer-rendered Billing Choice config to native', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const config = { billingChoiceScreenTypeAndroid: 'developer-rendered', enableBillingProgramAndroid: 'billing-choice', @@ -808,7 +805,7 @@ describe('Public API (src/index.ts)', () => { options: undefined, }, ])('retries deferred $name after initConnection', async (testCase) => { - (Platform as any).OS = testCase.platform; + Object.assign(Platform, {OS: testCase.platform}); mockIap[testCase.nativeMethod] = jest.fn().mockImplementationOnce(() => { throw new Error('Nitro runtime not installed'); }); @@ -824,7 +821,7 @@ describe('Public API (src/index.ts)', () => { describe('fetchProducts', () => { it('rejects when no SKUs provided', async () => { - await expect(IAP.fetchProducts({skus: [] as any} as any)).rejects.toThrow( + await expect(IAP.fetchProducts({skus: []})).rejects.toThrow( /No SKUs provided/, ); }); @@ -842,7 +839,7 @@ describe('Public API (src/index.ts)', () => { await expect( IAP.fetchProducts({ skus: ['coins'], - type: type as any, + type, }), ).rejects.toThrow(/Unsupported product type/); expect(mockIap.fetchProducts).not.toHaveBeenCalled(); @@ -850,7 +847,7 @@ describe('Public API (src/index.ts)', () => { ); it('validates and maps products for a single type', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.fetchProducts.mockResolvedValueOnce([ // valid { @@ -877,7 +874,7 @@ describe('Public API (src/index.ts)', () => { }); it('fetches both inapp and subs when type = all', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.fetchProducts.mockResolvedValueOnce([ { id: 'x', @@ -938,11 +935,11 @@ describe('Public API (src/index.ts)', () => { describe('requestPurchase', () => { it('rejects all without dispatching a purchase', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( IAP.requestPurchase({ request: {google: {skus: ['p1']}}, - type: 'all' as any, + type: 'all', }), ).rejects.toMatchObject({ code: ErrorCode.DeveloperError, @@ -952,37 +949,37 @@ describe('Public API (src/index.ts)', () => { }); it('requires apple.sku on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect( IAP.requestPurchase({ - request: {apple: {}} as any, + request: {apple: {}}, type: 'in-app', }), ).rejects.toThrow(/sku/); }); it('requires google.skus on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( IAP.requestPurchase({ - request: {google: {}} as any, + request: {google: {}}, type: 'in-app', }), ).rejects.toThrow(/skus/); }); it('throws on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect( IAP.requestPurchase({ - request: {apple: {sku: 'p1'}} as any, + request: {apple: {sku: 'p1'}}, type: 'in-app', }), ).rejects.toThrow(/Unsupported platform: web/); }); it('passes unified request to native', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: {google: {skus: ['p1']}}, type: 'in-app', @@ -995,7 +992,7 @@ describe('Public API (src/index.ts)', () => { }); it('iOS subs does not auto-set andDangerouslyFinishTransactionAutomatically when not provided', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await IAP.requestPurchase({ request: {apple: {sku: 'sub1'}}, type: 'subs', @@ -1008,7 +1005,7 @@ describe('Public API (src/index.ts)', () => { }); it('iOS passes withOffer through to native', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const offer = { identifier: 'offer-id', keyIdentifier: 'key-id', @@ -1033,7 +1030,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android subs fills empty subscriptionOffers array when missing', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: {google: {skus: ['sub1']}}, type: 'subs', @@ -1043,7 +1040,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android subs forwards subscriptionOffers when provided', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: { google: { @@ -1071,14 +1068,14 @@ describe('Public API (src/index.ts)', () => { ])( 'Android subs rejects malformed explicit offers without dispatching: %j', async (subscriptionOffers) => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( IAP.requestPurchase({ request: { google: { skus: ['sub1'], subscriptionOffers, - } as any, + }, }, type: 'subs', }), @@ -1103,16 +1100,16 @@ describe('Public API (src/index.ts)', () => { ])( 'rejects branch-mismatched Android options for %s without dispatching', async (type, google) => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( - IAP.requestPurchase({request: {google} as any, type: type as any}), + IAP.requestPurchase({request: {google}, type}), ).rejects.toThrow(/must match the selected product type/); expect(mockIap.requestPurchase).not.toHaveBeenCalled(); }, ); it('Android subs forwards subscriptionProductReplacementParams when provided', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: { google: { @@ -1134,7 +1131,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android subs does not include subscriptionProductReplacementParams when not provided', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: { google: { @@ -1151,7 +1148,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android forwards minimal in-app Billing Choice options', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: { google: { @@ -1171,7 +1168,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android forwards Billing Choice subscription replacement fields', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: { google: { @@ -1201,7 +1198,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android subs supports all replacement modes', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const replacementModes = [ 'unknown-replacement-mode', 'with-time-proration', @@ -1236,7 +1233,7 @@ describe('Public API (src/index.ts)', () => { // New tests for google/apple field support it('supports apple field (recommended) on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await IAP.requestPurchase({ request: {apple: {sku: 'premium_sub'}}, type: 'in-app', @@ -1248,7 +1245,7 @@ describe('Public API (src/index.ts)', () => { }); it('supports google field (recommended) on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: {google: {skus: ['premium_sub']}}, type: 'in-app', @@ -1260,7 +1257,7 @@ describe('Public API (src/index.ts)', () => { }); it('iOS passes advancedCommerceData through to native', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await IAP.requestPurchase({ request: { apple: { @@ -1275,7 +1272,7 @@ describe('Public API (src/index.ts)', () => { }); it('iOS passes advancedCommerceData with JSON format', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const advancedData = '{"signatureInfo": {"token": "affiliate_123"}}'; await IAP.requestPurchase({ request: { @@ -1291,7 +1288,7 @@ describe('Public API (src/index.ts)', () => { }); it('iOS subs forwards advanced subscription offer fields', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await IAP.requestPurchase({ request: { apple: { @@ -1324,7 +1321,7 @@ describe('Public API (src/index.ts)', () => { describe('getAvailablePurchases', () => { it('iOS path passes deprecation-compatible flags', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getAvailablePurchases.mockImplementationOnce(async () => []); await IAP.getAvailablePurchases({ alsoPublishToEventListenerIOS: true, @@ -1343,7 +1340,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android path merges inapp+subs results', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const nitro = (id: string) => ({ id: `t-${id}`, productId: id, @@ -1367,7 +1364,7 @@ describe('Public API (src/index.ts)', () => { }); it('rejects a mixed valid and malformed native purchase list', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const valid = { id: 'transaction-valid', productId: 'valid', @@ -1387,8 +1384,8 @@ describe('Public API (src/index.ts)', () => { }); it('rejects a non-array native purchase payload', async () => { - (Platform as any).OS = 'ios'; - mockIap.getAvailablePurchases.mockResolvedValueOnce(null as any); + Object.assign(Platform, {OS: 'ios'}); + mockIap.getAvailablePurchases.mockResolvedValueOnce(null); await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ code: 'billing-response-json-parse-error', @@ -1396,14 +1393,14 @@ describe('Public API (src/index.ts)', () => { }); it('preserves an authoritative empty native purchase list', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getAvailablePurchases.mockResolvedValueOnce([]); await expect(IAP.getAvailablePurchases()).resolves.toEqual([]); }); it('rejects a foreign store in an iOS available-purchase list', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getAvailablePurchases.mockResolvedValueOnce([ { id: 'foreign', @@ -1423,7 +1420,7 @@ describe('Public API (src/index.ts)', () => { }); it('rejects a foreign store in an Android available-purchase list', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.getAvailablePurchases .mockResolvedValueOnce([ { @@ -1488,7 +1485,7 @@ describe('Public API (src/index.ts)', () => { }); it('throws on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(IAP.getAvailablePurchases()).rejects.toThrow( /Unsupported platform: web/, ); @@ -1497,21 +1494,21 @@ describe('Public API (src/index.ts)', () => { describe('finishTransaction', () => { it('iOS requires purchase.id and returns success state', async () => { - (Platform as any).OS = 'ios'; - await expect( - IAP.finishTransaction({purchase: {id: ''} as any}), - ).rejects.toThrow(/required/); + Object.assign(Platform, {OS: 'ios'}); + await expect(IAP.finishTransaction({purchase: {id: ''}})).rejects.toThrow( + /required/, + ); mockIap.finishTransaction.mockResolvedValueOnce(true); await expect( - IAP.finishTransaction({purchase: {id: 'tid'} as any}), + IAP.finishTransaction({purchase: {id: 'tid'}}), ).resolves.toBeUndefined(); }); it('Android requires token; maps consume flag', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( - IAP.finishTransaction({purchase: {productId: 'p'} as any}), + IAP.finishTransaction({purchase: {productId: 'p'}}), ).rejects.toThrow(/token/i); mockIap.finishTransaction.mockResolvedValueOnce({ @@ -1521,7 +1518,7 @@ describe('Public API (src/index.ts)', () => { purchaseToken: 'tok', }); await IAP.finishTransaction({ - purchase: {productId: 'p', purchaseToken: 'tok'} as any, + purchase: {productId: 'p', purchaseToken: 'tok'}, isConsumable: true, }); expect(mockIap.finishTransaction).toHaveBeenCalledWith({ @@ -1530,17 +1527,17 @@ describe('Public API (src/index.ts)', () => { }); it('iOS: treats already-finished error as success', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.finishTransaction.mockRejectedValueOnce( new Error('Transaction not found'), ); await expect( - IAP.finishTransaction({purchase: {id: 'tid'} as any}), + IAP.finishTransaction({purchase: {id: 'tid'}}), ).resolves.toBeUndefined(); }); it('iOS: propagates native finish failures', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const error = new Error( JSON.stringify({ code: 'service-error', @@ -1550,7 +1547,7 @@ describe('Public API (src/index.ts)', () => { mockIap.finishTransaction.mockRejectedValueOnce(error); await expect( - IAP.finishTransaction({purchase: {id: 'tid'} as any}), + IAP.finishTransaction({purchase: {id: 'tid'}}), ).rejects.toMatchObject({ code: ErrorCode.ServiceError, message: 'StoreKit network failure', @@ -1558,16 +1555,16 @@ describe('Public API (src/index.ts)', () => { }); it('throws on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect( - IAP.finishTransaction({purchase: {id: 'tid'} as any}), + IAP.finishTransaction({purchase: {id: 'tid'}}), ).rejects.toThrow(/Unsupported platform: web/); }); }); describe('storefront helpers', () => { it('getStorefront uses unified native method when available on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getStorefront = jest.fn(async () => 'USA'); await expect(IAP.getStorefront()).resolves.toBe('USA'); expect(mockIap.getStorefront).toHaveBeenCalledTimes(1); @@ -1576,7 +1573,7 @@ describe('Public API (src/index.ts)', () => { it('getStorefront uses unified method on Android', async () => { const expected = 'KOR'; mockIap.getStorefront = jest.fn(async () => expected); - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.getStorefront()).resolves.toBe(expected); expect(mockIap.getStorefront).toHaveBeenCalledTimes(1); }); @@ -1584,7 +1581,7 @@ describe('Public API (src/index.ts)', () => { it.each([null, undefined, '', ' '])( 'getStorefront rejects an empty native value (%p)', async (value) => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.getStorefront = jest.fn(async () => value); await expect(IAP.getStorefront()).rejects.toMatchObject({ @@ -1595,7 +1592,7 @@ describe('Public API (src/index.ts)', () => { ); it('getStorefront normalizes native exceptions', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.getStorefront = jest.fn(async () => { throw new Error('storefront exploded'); }); @@ -1607,7 +1604,7 @@ describe('Public API (src/index.ts)', () => { }); it('getStorefront rejects unsupported platforms', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(IAP.getStorefront()).rejects.toMatchObject({ code: IAP.ErrorCode.FeatureNotSupported, @@ -1618,9 +1615,9 @@ describe('Public API (src/index.ts)', () => { describe('iOS-only helpers', () => { it('getAppTransactionIOS returns value on iOS and throws on Android', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect(IAP.getAppTransactionIOS()).resolves.toBeNull(); - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.getAppTransactionIOS()).rejects.toThrow( /only available on iOS/, ); @@ -1668,7 +1665,7 @@ describe('Public API (src/index.ts)', () => { }); it('presentCodeRedemptionSheetIOS returns the verified purchase', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.presentCodeRedemptionSheetIOS.mockResolvedValueOnce({ id: 'redeemed-transaction', transactionId: 'redeemed-transaction', @@ -1687,12 +1684,12 @@ describe('Public API (src/index.ts)', () => { }); it('presentCodeRedemptionSheetIOS returns null on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.presentCodeRedemptionSheetIOS()).resolves.toBeNull(); }); it('getPendingTransactionsIOS maps purchases', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const nitro = { id: 't1', transactionId: 't1', @@ -1709,7 +1706,7 @@ describe('Public API (src/index.ts)', () => { }); it('showManageSubscriptionsIOS maps purchases', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const nitro = { id: 't2', transactionId: 't2', @@ -1734,7 +1731,7 @@ describe('Public API (src/index.ts)', () => { ])( '%s rejects a mixed non-Apple batch atomically', async (apiName, nativeName) => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const valid = { id: 'apple-transaction', transactionId: 'apple-transaction', @@ -1757,12 +1754,12 @@ describe('Public API (src/index.ts)', () => { ); it('showManageSubscriptionsIOS returns [] on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.showManageSubscriptionsIOS()).resolves.toEqual([]); }); it('getPromotedProductIOS maps the native product', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const nitroProduct = { id: 'sku2', title: 'Title2', @@ -1779,13 +1776,13 @@ describe('Public API (src/index.ts)', () => { }); it('clearTransactionIOS resolves without throwing', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.clearTransactionIOS = jest.fn(async () => undefined); await expect(IAP.clearTransactionIOS()).resolves.toBe(true); }); it('clearTransactionIOS surfaces native failures', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.clearTransactionIOS = jest.fn(async () => { throw {code: 'service-error', message: 'Clear failed'}; }); @@ -1797,13 +1794,13 @@ describe('Public API (src/index.ts)', () => { }); it('beginRefundRequestIOS returns status string', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.beginRefundRequestIOS = jest.fn(async () => 'success'); await expect(IAP.beginRefundRequestIOS('sku')).resolves.toBe('success'); }); it('subscriptionStatusIOS converts items', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.subscriptionStatusIOS = jest.fn(async () => [ { state: 1, @@ -1818,7 +1815,7 @@ describe('Public API (src/index.ts)', () => { }); it('currentEntitlementIOS and latestTransactionIOS map purchases', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const nitro = { id: 't3', transactionId: 't3', @@ -1839,26 +1836,26 @@ describe('Public API (src/index.ts)', () => { }); it('isEligibleForIntroOfferIOS returns boolean', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.isEligibleForIntroOfferIOS = jest.fn(async () => true); await expect(IAP.isEligibleForIntroOfferIOS('group')).resolves.toBe(true); }); it('getReceiptDataIOS returns string', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getReceiptDataIOS = jest.fn(async () => 'r'); await expect(IAP.getReceiptDataIOS()).resolves.toBe('r'); }); it('requestReceiptRefreshIOS prefers native method when available', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.requestReceiptRefreshIOS = jest.fn(async () => 'refresh'); await expect(IAP.requestReceiptRefreshIOS()).resolves.toBe('refresh'); expect(mockIap.requestReceiptRefreshIOS).toHaveBeenCalled(); }); it('requestReceiptRefreshIOS falls back to getReceiptDataIOS when missing', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); delete mockIap.requestReceiptRefreshIOS; mockIap.getReceiptDataIOS = jest.fn(async () => 'fallback-refresh'); await expect(IAP.requestReceiptRefreshIOS()).resolves.toBe( @@ -1868,25 +1865,25 @@ describe('Public API (src/index.ts)', () => { }); it('isTransactionVerifiedIOS returns boolean', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.isTransactionVerifiedIOS = jest.fn(async () => true); await expect(IAP.isTransactionVerifiedIOS('sku')).resolves.toBe(true); }); it('getTransactionJwsIOS returns string', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getTransactionJwsIOS = jest.fn(async () => 'jws'); await expect(IAP.getTransactionJwsIOS('sku')).resolves.toBe('jws'); }); it('syncIOS calls native sync', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.syncIOS = jest.fn(async () => true); await expect(IAP.syncIOS()).resolves.toBe(true); }); it('syncIOS preserves Nitro user cancellation without error logging', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.syncIOS = jest.fn(async () => { throw new Error( 'Error Domain=com.margelo.nitro.rniap Code=-1 ' + @@ -1903,14 +1900,14 @@ describe('Public API (src/index.ts)', () => { }); it('restorePurchases on iOS calls syncIOS first', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.syncIOS = jest.fn(async () => true); await IAP.restorePurchases(); expect(mockIap.syncIOS).toHaveBeenCalled(); }); it('restorePurchases on iOS rejects when syncIOS returns false', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.syncIOS = jest.fn(async () => false); await expect(IAP.restorePurchases()).rejects.toMatchObject({ @@ -1923,7 +1920,7 @@ describe('Public API (src/index.ts)', () => { describe('Android user choice billing listener', () => { it('fans out native events, isolates callbacks, and removes listeners', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const first = IAP.userChoiceBillingListenerAndroid(() => { throw new Error('consumer failed'); }); @@ -1954,7 +1951,7 @@ describe('Public API (src/index.ts)', () => { }); it('returns an inert subscription outside Android', () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const subscription = IAP.userChoiceBillingListenerAndroid(jest.fn()); expect(() => subscription.remove()).not.toThrow(); expect( @@ -1963,7 +1960,7 @@ describe('Public API (src/index.ts)', () => { }); it('reattaches the listener after Nitro initializes', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.addUserChoiceBillingListenerAndroid.mockImplementationOnce(() => { throw new Error('Nitro runtime not installed'); }); @@ -1983,7 +1980,7 @@ describe('Public API (src/index.ts)', () => { }); it('surfaces unexpected native listener failures', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.addUserChoiceBillingListenerAndroid.mockImplementationOnce(() => { throw new Error('native listener failed'); }); @@ -2005,7 +2002,7 @@ describe('Public API (src/index.ts)', () => { describe('Android-only wrappers', () => { it('acknowledgePurchaseAndroid calls unified finishTransaction', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.finishTransaction.mockResolvedValueOnce({ responseCode: 0, code: '0', @@ -2020,7 +2017,7 @@ describe('Public API (src/index.ts)', () => { }); it('consumePurchaseAndroid calls unified finishTransaction', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.finishTransaction.mockResolvedValueOnce({ responseCode: 0, code: '0', @@ -2035,14 +2032,14 @@ describe('Public API (src/index.ts)', () => { }); it('openRedeemOfferCodeAndroid delegates to the native store handler', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.openRedeemOfferCodeAndroid.mockResolvedValueOnce(true); await expect(IAP.openRedeemOfferCodeAndroid()).resolves.toBe(true); expect(mockIap.openRedeemOfferCodeAndroid).toHaveBeenCalledTimes(1); }); it('openRedeemOfferCodeAndroid throws on non-Android', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect(IAP.openRedeemOfferCodeAndroid()).rejects.toThrow( 'openRedeemOfferCodeAndroid is only supported on Android', ); @@ -2050,7 +2047,7 @@ describe('Public API (src/index.ts)', () => { }); it('openRedeemOfferCodeAndroid preserves unsupported store results', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.openRedeemOfferCodeAndroid.mockResolvedValueOnce(false); await expect(IAP.openRedeemOfferCodeAndroid()).resolves.toBe(false); }); @@ -2058,7 +2055,7 @@ describe('Public API (src/index.ts)', () => { describe('verifyPurchase', () => { it('iOS path maps NitroPurchaseVerificationResultIOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.verifyPurchase.mockResolvedValueOnce({ isValid: true, receiptData: 'r', @@ -2078,7 +2075,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android path maps NitroPurchaseVerificationResultAndroid', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.verifyPurchase.mockResolvedValueOnce({ isValid: false, autoRenewing: false, @@ -2118,7 +2115,7 @@ describe('Public API (src/index.ts)', () => { }); it('Horizon path forwards options and maps its result variant', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.verifyPurchase.mockResolvedValueOnce({ isValid: true, grantTime: 1744148687, @@ -2150,7 +2147,7 @@ describe('Public API (src/index.ts)', () => { }); it('uses the normalized Google variant when Horizon options are empty', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.verifyPurchase.mockResolvedValueOnce({ isValid: false, productId: 'sku', @@ -2165,7 +2162,7 @@ describe('Public API (src/index.ts)', () => { accessToken: 'acc', }, horizon: {}, - } as any); + }); expect(mockIap.verifyPurchase).toHaveBeenCalledWith({ apple: null, @@ -2191,46 +2188,46 @@ describe('Public API (src/index.ts)', () => { describe('Non‑iOS branches', () => { it('isEligibleForIntroOfferIOS returns false on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.isEligibleForIntroOfferIOS('group')).resolves.toBe( false, ); }); it('getReceiptDataIOS throws on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.getReceiptDataIOS()).rejects.toThrow( /only available on iOS/, ); }); it('isTransactionVerifiedIOS returns false on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.isTransactionVerifiedIOS('sku')).resolves.toBe(false); }); it('getTransactionJwsIOS returns null on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.getTransactionJwsIOS('sku')).resolves.toBeNull(); }); it('getPendingTransactionsIOS returns [] on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.getPendingTransactionsIOS()).resolves.toEqual([]); }); it('currentEntitlementIOS returns null on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.currentEntitlementIOS('sku')).resolves.toBeNull(); }); it('latestTransactionIOS returns null on non‑iOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect(IAP.latestTransactionIOS('sku')).resolves.toBeNull(); }); it('restorePurchases on Android does not call syncIOS', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.syncIOS = jest.fn(async () => true); await expect(IAP.restorePurchases()).resolves.toBeUndefined(); expect(mockIap.syncIOS).not.toHaveBeenCalled(); @@ -2239,7 +2236,7 @@ describe('Public API (src/index.ts)', () => { describe('Cross‑platform helpers', () => { it('deepLinkToSubscriptions calls Android native deeplink when on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.deepLinkToSubscriptionsAndroid = jest.fn(async () => undefined); await expect( IAP.deepLinkToSubscriptions({ @@ -2254,14 +2251,14 @@ describe('Public API (src/index.ts)', () => { }); it('deepLinkToSubscriptions uses iOS deeplink when available', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.deepLinkToSubscriptionsIOS = jest.fn(async () => true); await expect(IAP.deepLinkToSubscriptions()).resolves.toBeUndefined(); expect(mockIap.deepLinkToSubscriptionsIOS).toHaveBeenCalled(); }); it('deepLinkToSubscriptions falls back to manage subscriptions when deeplink missing', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); delete mockIap.deepLinkToSubscriptionsIOS; mockIap.showManageSubscriptionsIOS = jest.fn(async () => []); await expect(IAP.deepLinkToSubscriptions()).resolves.toBeUndefined(); @@ -2269,7 +2266,7 @@ describe('Public API (src/index.ts)', () => { }); it('deepLinkToSubscriptions surfaces iOS native failures', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.deepLinkToSubscriptionsIOS = jest.fn(async () => { throw new Error('scene missing'); }); @@ -2279,14 +2276,14 @@ describe('Public API (src/index.ts)', () => { }); it('deepLinkToSubscriptions throws on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(IAP.deepLinkToSubscriptions()).rejects.toThrow( 'Unsupported platform: web', ); }); it('openRedeemOfferCode resolves the synchronously reported purchase on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.presentCodeRedemptionSheetIOS.mockResolvedValueOnce({ id: 'redeemed-transaction', transactionId: 'redeemed-transaction', @@ -2306,13 +2303,13 @@ describe('Public API (src/index.ts)', () => { }); it('openRedeemOfferCode resolves null when the iOS sheet reports nothing', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.presentCodeRedemptionSheetIOS.mockResolvedValueOnce(null); await expect(IAP.openRedeemOfferCode()).resolves.toBeNull(); }); it('openRedeemOfferCode launches the Play redeem page and resolves null on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.openRedeemOfferCodeAndroid.mockResolvedValueOnce(true); await expect(IAP.openRedeemOfferCode()).resolves.toBeNull(); expect(mockIap.openRedeemOfferCodeAndroid).toHaveBeenCalledTimes(1); @@ -2343,7 +2340,7 @@ describe('Public API (src/index.ts)', () => { }); it('openRedeemOfferCode throws on unsupported platform', async () => { - (Platform as any).OS = 'web'; + Object.assign(Platform, {OS: 'web'}); await expect(IAP.openRedeemOfferCode()).rejects.toThrow( 'Unsupported platform: web', ); @@ -2366,7 +2363,7 @@ describe('Public API (src/index.ts)', () => { describe('getActiveSubscriptions', () => { it('iOS: should call native getActiveSubscriptions and map results', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockActiveSubscriptions = [ { @@ -2429,7 +2426,7 @@ describe('Public API (src/index.ts)', () => { }); it('iOS: should pass subscription IDs to native method', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getActiveSubscriptions.mockResolvedValueOnce([]); @@ -2442,7 +2439,7 @@ describe('Public API (src/index.ts)', () => { }); it('Android: should call native getActiveSubscriptions with Android fields', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockActiveSubscriptions = [ { @@ -2502,7 +2499,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return empty array when no subscriptions available', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getActiveSubscriptions.mockResolvedValueOnce([]); const result = await IAP.getActiveSubscriptions(); @@ -2511,7 +2508,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle errors and rethrow them', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const error = new Error('Failed to fetch'); mockIap.getActiveSubscriptions.mockRejectedValueOnce(error); @@ -2523,7 +2520,7 @@ describe('Public API (src/index.ts)', () => { describe('hasActiveSubscriptions', () => { it('should return true when there are active subscriptions', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getActiveSubscriptions.mockResolvedValueOnce([ {productId: 'sub1', isActive: true}, ]); @@ -2534,7 +2531,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return false when there are no active subscriptions', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getActiveSubscriptions.mockResolvedValueOnce([]); const result = await IAP.hasActiveSubscriptions(); @@ -2545,7 +2542,7 @@ describe('Public API (src/index.ts)', () => { it.each(['ios', 'android'] as const)( 'should reject when subscription status cannot be determined on %s', async (platform) => { - (Platform as any).OS = platform; + Object.assign(Platform, {OS: platform}); const error = new Error('Failed to fetch'); mockIap.getActiveSubscriptions.mockRejectedValueOnce(error); @@ -2563,7 +2560,7 @@ describe('Public API (src/index.ts)', () => { }); it('should call native verifyPurchaseWithProvider with correct params', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { provider: 'iapkit', iapkit: { @@ -2615,7 +2612,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle Android verification', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockResult = { provider: 'iapkit', iapkit: { @@ -2642,7 +2639,7 @@ describe('Public API (src/index.ts)', () => { }); it('should pass Amazon IAPKit payloads through on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockResult = { provider: 'iapkit', iapkit: { @@ -2684,7 +2681,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw error when provider is not iapkit', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { provider: 'none', iapkit: null, @@ -2703,7 +2700,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle verification failure states', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { provider: 'iapkit', iapkit: { @@ -2727,7 +2724,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle native errors', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.verifyPurchaseWithProvider.mockRejectedValueOnce( new Error('Network error'), ); @@ -2744,7 +2741,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle null iapkit param', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { provider: 'iapkit', iapkit: [], @@ -2762,7 +2759,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle various IAPKit purchase states', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const states = [ 'entitled', 'pending-acknowledgment', @@ -2795,7 +2792,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle inauthentic verification response', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { provider: 'iapkit', iapkit: {isValid: false, state: 'inauthentic', store: 'apple'}, @@ -2815,7 +2812,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle ready-to-consume state for consumables', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockResult = { provider: 'iapkit', iapkit: {isValid: true, state: 'ready-to-consume', store: 'google'}, @@ -2838,7 +2835,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle pending-acknowledgment state for subscriptions', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const mockResult = { provider: 'iapkit', iapkit: { @@ -2867,7 +2864,7 @@ describe('Public API (src/index.ts)', () => { describe('developerProvidedBillingListenerAndroid (External Payments 8.3.0+)', () => { it('should warn and no-op on non-Android', () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const sub = IAP.developerProvidedBillingListenerAndroid(jest.fn()); expect(typeof sub.remove).toBe('function'); @@ -2879,7 +2876,7 @@ describe('Public API (src/index.ts)', () => { }); it('should attach listener and forward details on Android', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.addDeveloperProvidedBillingListenerAndroid = jest.fn(); mockIap.removeDeveloperProvidedBillingListenerAndroid = jest.fn(); @@ -2919,7 +2916,7 @@ describe('Public API (src/index.ts)', () => { describe('enableBillingProgramAndroid', () => { it('should call native method on Android', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); IAP.enableBillingProgramAndroid('external-offer'); expect(mockIap.enableBillingProgramAndroid).toHaveBeenCalledWith( 'external-offer', @@ -2927,7 +2924,7 @@ describe('Public API (src/index.ts)', () => { }); it('should support external-payments program (8.3.0+)', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); IAP.enableBillingProgramAndroid('external-payments'); expect(mockIap.enableBillingProgramAndroid).toHaveBeenCalledWith( 'external-payments', @@ -2935,7 +2932,7 @@ describe('Public API (src/index.ts)', () => { }); it('should support billing-choice program (9.1.0+)', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); IAP.enableBillingProgramAndroid('billing-choice'); expect(mockIap.enableBillingProgramAndroid).toHaveBeenCalledWith( 'billing-choice', @@ -2943,7 +2940,7 @@ describe('Public API (src/index.ts)', () => { }); it('should warn and return early on non-Android', () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); IAP.enableBillingProgramAndroid('external-offer'); expect(mockIap.enableBillingProgramAndroid).not.toHaveBeenCalled(); expect(console.warn).toHaveBeenCalledWith( @@ -2953,7 +2950,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle errors gracefully', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.enableBillingProgramAndroid.mockImplementationOnce(() => { throw new Error('Native error'); }); @@ -2965,7 +2962,7 @@ describe('Public API (src/index.ts)', () => { }); it('should support external-content-link program', () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); IAP.enableBillingProgramAndroid('external-content-link'); expect(mockIap.enableBillingProgramAndroid).toHaveBeenCalledWith( 'external-content-link', @@ -2975,7 +2972,7 @@ describe('Public API (src/index.ts)', () => { describe('isBillingProgramAvailableAndroid', () => { it('should return availability result on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.isBillingProgramAvailableAndroid.mockResolvedValueOnce({ billingProgram: 'external-offer', isAvailable: true, @@ -2992,7 +2989,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return false when program not available', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.isBillingProgramAvailableAndroid.mockResolvedValueOnce({ billingProgram: 'external-offer', isAvailable: false, @@ -3005,14 +3002,14 @@ describe('Public API (src/index.ts)', () => { }); it('should throw on non-Android', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect( IAP.isBillingProgramAvailableAndroid('external-offer'), ).rejects.toThrow('Billing Programs API is only supported on Android'); }); it('should handle native errors', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.isBillingProgramAvailableAndroid.mockRejectedValueOnce( new Error('Service unavailable'), ); @@ -3023,7 +3020,7 @@ describe('Public API (src/index.ts)', () => { }); it('should support external-content-link program', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.isBillingProgramAvailableAndroid.mockResolvedValueOnce({ billingProgram: 'external-content-link', isAvailable: true, @@ -3039,7 +3036,7 @@ describe('Public API (src/index.ts)', () => { describe('getBillingChoiceInfoAndroid', () => { it('should request Billing Choice info with defaults on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const result = await IAP.getBillingChoiceInfoAndroid({}); expect(mockIap.getBillingChoiceInfoAndroid).toHaveBeenCalledWith({ @@ -3053,8 +3050,8 @@ describe('Public API (src/index.ts)', () => { }); it('should request Billing Choice info with defaults when params are omitted', async () => { - (Platform as any).OS = 'android'; - const result = await (IAP.getBillingChoiceInfoAndroid as any)(); + Object.assign(Platform, {OS: 'android'}); + const result = await IAP.getBillingChoiceInfoAndroid(); expect(mockIap.getBillingChoiceInfoAndroid).toHaveBeenCalledWith({ billingProgram: 'billing-choice', @@ -3067,7 +3064,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw on non-Android', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect(IAP.getBillingChoiceInfoAndroid({})).rejects.toThrow( 'Billing Choice API is only supported on Android', ); @@ -3076,7 +3073,7 @@ describe('Public API (src/index.ts)', () => { describe('createBillingProgramReportingDetailsAndroid', () => { it('should return reporting details with token on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.createBillingProgramReportingDetailsAndroid.mockResolvedValueOnce( { billingProgram: 'external-offer', @@ -3097,7 +3094,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw on non-Android', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect( IAP.createBillingProgramReportingDetailsAndroid('external-offer'), ).rejects.toThrow('Billing Programs API is only supported on Android'); @@ -3107,7 +3104,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle native errors', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.createBillingProgramReportingDetailsAndroid.mockRejectedValueOnce( new Error('Token creation failed'), ); @@ -3118,7 +3115,7 @@ describe('Public API (src/index.ts)', () => { }); it('should support external-content-link program', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.createBillingProgramReportingDetailsAndroid.mockResolvedValueOnce( { billingProgram: 'external-content-link', @@ -3135,7 +3132,7 @@ describe('Public API (src/index.ts)', () => { }); it('should pass developerBillingType for Billing Choice reporting details', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.createBillingProgramReportingDetailsAndroid.mockResolvedValueOnce( { billingProgram: 'billing-choice', @@ -3157,7 +3154,7 @@ describe('Public API (src/index.ts)', () => { describe('showBillingProgramInformationDialogAndroid', () => { it('should show Billing Choice information dialog with default program', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const result = await IAP.showBillingProgramInformationDialogAndroid({ externalTransactionToken: 'choice-token-123', }); @@ -3173,7 +3170,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw on non-Android', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect( IAP.showBillingProgramInformationDialogAndroid({ externalTransactionToken: 'choice-token-123', @@ -3187,7 +3184,7 @@ describe('Public API (src/index.ts)', () => { describe('showInAppMessagesAndroid', () => { it('should delegate to native in-app messages method', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const result = await IAP.showInAppMessagesAndroid({ categories: ['transactional'], }); @@ -3199,7 +3196,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw on non-Android', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect( IAP.showInAppMessagesAndroid({categories: ['transactional']}), ).rejects.toThrow('In-app messages are only supported on Android'); @@ -3216,7 +3213,7 @@ describe('Public API (src/index.ts)', () => { }; it('should return true when user accepts on Android', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.launchExternalLinkAndroid.mockResolvedValueOnce(true); const result = await IAP.launchExternalLinkAndroid(defaultParams); @@ -3231,7 +3228,7 @@ describe('Public API (src/index.ts)', () => { }); it('forwards Billing Choice external transaction token', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const params = { ...defaultParams, billingProgram: 'billing-choice' as const, @@ -3244,7 +3241,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return false when user declines', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.launchExternalLinkAndroid.mockResolvedValueOnce(false); const result = await IAP.launchExternalLinkAndroid(defaultParams); @@ -3253,14 +3250,14 @@ describe('Public API (src/index.ts)', () => { }); it('should throw on non-Android', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); await expect( IAP.launchExternalLinkAndroid(defaultParams), ).rejects.toThrow('Billing Programs API is only supported on Android'); }); it('should handle native errors', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.launchExternalLinkAndroid.mockRejectedValueOnce( new Error('Launch failed'), ); @@ -3271,7 +3268,7 @@ describe('Public API (src/index.ts)', () => { }); it('should support external-content-link program', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.launchExternalLinkAndroid.mockResolvedValueOnce(true); const params = { @@ -3287,7 +3284,7 @@ describe('Public API (src/index.ts)', () => { }); it('should support caller-will-launch-link mode', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); mockIap.launchExternalLinkAndroid.mockResolvedValueOnce(true); const params = { @@ -3307,7 +3304,7 @@ describe('Public API (src/index.ts)', () => { describe('ExternalPurchaseCustomLink APIs (iOS 18.1+)', () => { describe('isEligibleForExternalPurchaseCustomLinkIOS', () => { it('should return true when eligible on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.isEligibleForExternalPurchaseCustomLinkIOS = jest.fn( async () => true, ); @@ -3321,7 +3318,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return false when not eligible on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.isEligibleForExternalPurchaseCustomLinkIOS = jest.fn( async () => false, ); @@ -3332,7 +3329,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return false on non-iOS platforms', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const result = await IAP.isEligibleForExternalPurchaseCustomLinkIOS(); @@ -3340,7 +3337,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return false on error', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.isEligibleForExternalPurchaseCustomLinkIOS = jest.fn( async () => { throw new Error('Feature not supported'); @@ -3355,7 +3352,7 @@ describe('Public API (src/index.ts)', () => { describe('getExternalPurchaseCustomLinkTokenIOS', () => { it('should return token for acquisition type on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { token: 'external-purchase-token-123', error: null, @@ -3375,7 +3372,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return token for services type on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { token: 'services-token-456', error: null, @@ -3394,7 +3391,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw on non-iOS platforms', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( IAP.getExternalPurchaseCustomLinkTokenIOS('acquisition'), @@ -3404,7 +3401,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw native errors', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.getExternalPurchaseCustomLinkTokenIOS = jest.fn(async () => { throw new Error('Token generation failed'); }); @@ -3417,7 +3414,7 @@ describe('Public API (src/index.ts)', () => { describe('showExternalPurchaseCustomLinkNoticeIOS', () => { it('should return continued=true when user agrees on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { continued: true, error: null, @@ -3437,7 +3434,7 @@ describe('Public API (src/index.ts)', () => { }); it('should return continued=false when user declines on iOS', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { continued: false, error: null, @@ -3453,7 +3450,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw on non-iOS platforms', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await expect( IAP.showExternalPurchaseCustomLinkNoticeIOS('browser'), @@ -3463,7 +3460,7 @@ describe('Public API (src/index.ts)', () => { }); it('should throw native errors', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); mockIap.showExternalPurchaseCustomLinkNoticeIOS = jest.fn(async () => { throw new Error('Notice display failed'); }); @@ -3474,7 +3471,7 @@ describe('Public API (src/index.ts)', () => { }); it('should handle unspecified noticeType gracefully', async () => { - (Platform as any).OS = 'ios'; + Object.assign(Platform, {OS: 'ios'}); const mockResult = { continued: true, error: null, @@ -3684,7 +3681,7 @@ describe('Public API (src/index.ts)', () => { replaceNativeMethod('initConnection', jest.fn().mockResolvedValue(true)); expect(IAP.isNitroReady()).toBe(true); - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const existingListener = jest.fn(); IAP.purchaseUpdatedListener(existingListener); replaceNativeMethod( @@ -3746,7 +3743,7 @@ describe('Public API (src/index.ts)', () => { }), }, ])('surfaces Android $name failures', async (testCase) => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); const nativeError = new Error('android failure'); replaceNativeMethod( testCase.nativeMethod, @@ -3888,7 +3885,7 @@ describe('Public API (src/index.ts)', () => { ])( 'rolls back a failed $name registration', ({platform, api, nativeMethod, payload}) => { - (Platform as any).OS = platform; + Object.assign(Platform, {OS: platform}); mockIap[nativeMethod].mockImplementationOnce(() => { throw new Error('native listener failed'); }); @@ -3951,7 +3948,7 @@ describe('Public API (src/index.ts)', () => { }); it('forwards optional Android purchase fields', async () => { - (Platform as any).OS = 'android'; + Object.assign(Platform, {OS: 'android'}); await IAP.requestPurchase({ request: { google: { diff --git a/libraries/react-native-iap/src/__tests__/standardized-offer-types.test.ts b/libraries/react-native-iap/src/__tests__/standardized-offer-types.test.ts index 28b21eeab..c13b5bad3 100644 --- a/libraries/react-native-iap/src/__tests__/standardized-offer-types.test.ts +++ b/libraries/react-native-iap/src/__tests__/standardized-offer-types.test.ts @@ -1,13 +1,8 @@ /** - * Tests for standardized offer types and the input field naming convention. - * - * Key principle tested here: - * - Response types (DiscountOffer, SubscriptionOffer, etc.) use Android suffix for platform-specific fields - * - Input types (RequestPurchaseAndroidProps) do NOT use Android suffix since the parent type indicates platform - * - * Example: - * - Response: DiscountOffer.offerTokenAndroid (suffix because it's cross-platform type) - * - Input: RequestPurchaseAndroidProps.offerToken (no suffix, parent type is Android-specific) + * Tests for standardized offer types and the input field naming convention: + * cross-platform response types suffix platform-specific fields + * (DiscountOffer.offerTokenAndroid); Android input types do not, because the + * parent type already names the platform (RequestPurchaseAndroidProps.offerToken). */ import type { @@ -215,8 +210,7 @@ describe('Standardized Offer Types', () => { describe('Product with offer fields', () => { it('should have correct type structure for ProductAndroid with discountOffers', () => { - // This test validates the type structure rather than the API call - // The actual fetchProducts conversion is tested in index.test.ts + // Type structure only; index.test.ts covers the fetchProducts conversion. const mockProduct: ProductAndroid = { id: 'test_product', title: 'Test Product', @@ -248,8 +242,7 @@ describe('Standardized Offer Types', () => { }); it('should have correct type structure for ProductSubscriptionAndroid with subscriptionOffers', () => { - // This test validates the type structure rather than the API call - // The actual fetchProducts conversion is tested in index.test.ts + // Type structure only; index.test.ts covers the fetchProducts conversion. const mockSubscription: ProductSubscriptionAndroid = { id: 'subscription_product', title: 'Premium Subscription', @@ -353,9 +346,7 @@ describe('Standardized Offer Types', () => { describe('RequestPurchaseAndroidProps with offerToken', () => { it('should support offerToken for one-time purchase discounts', () => { - // This tests the type structure for one-time purchase discount offers - // introduced in Google Play Billing Library 8.0 - // Note: Input fields no longer have Android suffix (parent type indicates platform) + // One-time purchase discount offers (Google Play Billing Library 8.0). const purchaseRequest: RequestPurchaseAndroidProps = { skus: ['premium_upgrade'], offerToken: 'discount_offer_token_abc123', @@ -382,8 +373,6 @@ describe('Standardized Offer Types', () => { }); it('should extract offerTokenAndroid from DiscountOffer for purchase input', () => { - // Simulate getting a product with discount offers - // Note: Response types (DiscountOffer) keep Android suffix const discountOffer: DiscountOffer = { id: 'flash_sale', displayPrice: '$2.99', @@ -394,8 +383,6 @@ describe('Standardized Offer Types', () => { percentageDiscountAndroid: 50, }; - // Build purchase request using the offer token from the discount offer - // Input field uses offerToken (no suffix), value comes from response's offerTokenAndroid const purchaseRequest: RequestPurchaseAndroidProps = { skus: ['premium_upgrade'], offerToken: discountOffer.offerTokenAndroid ?? undefined, @@ -406,9 +393,8 @@ describe('Standardized Offer Types', () => { }); it('should support isOfferPersonalized for EU compliance', () => { - // isOfferPersonalized indicates when the price was customized for this user - // Required for EU Digital Services Act compliance - // Note: Input field uses isOfferPersonalized (no Android suffix) + // Flags a price customized for this user; required for EU Digital + // Services Act compliance. const personalizedRequest: RequestPurchaseAndroidProps = { skus: ['premium_product'], isOfferPersonalized: true, @@ -425,8 +411,6 @@ describe('Standardized Offer Types', () => { it('should combine discountOffers offerTokenAndroid with purchase request', () => { // Full workflow: product → discount offer → purchase request - // Response type (ProductAndroid.discountOffers) uses offerTokenAndroid - // Input type (purchase request) uses offerToken (no suffix) const mockProduct: ProductAndroid = { id: 'consumable_gems', title: '100 Gems', diff --git a/libraries/react-native-iap/src/__tests__/utils/coverage-regression.test.ts b/libraries/react-native-iap/src/__tests__/utils/coverage-regression.test.ts index ceb11f132..74d4eae46 100644 --- a/libraries/react-native-iap/src/__tests__/utils/coverage-regression.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/coverage-regression.test.ts @@ -5,7 +5,7 @@ import {getSuccessFromPurchaseVariant} from '../../utils/purchase'; describe('utility fallback coverage', () => { afterEach(() => { delete process.env.RN_IAP_DEV_MODE; - delete (global as any).RN_IAP_DEV_MODE; + Reflect.deleteProperty(globalThis, 'RN_IAP_DEV_MODE'); jest.restoreAllMocks(); }); @@ -50,7 +50,7 @@ describe('utility fallback coverage', () => { process.env.RN_IAP_DEV_MODE = 'true'; RnIapConsole.log('environment'); delete process.env.RN_IAP_DEV_MODE; - (global as any).RN_IAP_DEV_MODE = true; + Object.assign(globalThis, {RN_IAP_DEV_MODE: true}); RnIapConsole.info('global'); expect(log).toHaveBeenCalledWith('[RN-IAP]', 'environment'); diff --git a/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts b/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts index ef7e82375..b2b690dc7 100644 --- a/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts @@ -17,16 +17,16 @@ describe('utils/errorMapping', () => { isUserCancelledError({ code: ErrorCode.UserCancelled, message: 'x', - } as any), - ).toBe(true); - expect( - isUserCancelledError({code: 'E_USER_CANCELED', message: 'x'} as any), + }), ).toBe(true); + expect(isUserCancelledError({code: 'E_USER_CANCELED', message: 'x'})).toBe( + true, + ); expect( isUserCancelledError({ code: ErrorCode.NetworkError, message: 'x', - } as any), + }), ).toBe(false); }); @@ -41,13 +41,13 @@ describe('utils/errorMapping', () => { ErrorCode.SyncError, ]; for (const code of recoverables) { - expect(isRecoverableError({code, message: 'x'} as any)).toBe(true); + expect(isRecoverableError({code, message: 'x'})).toBe(true); } expect( isRecoverableError({ code: ErrorCode.UserCancelled, message: 'x', - } as any), + }), ).toBe(false); }); @@ -56,19 +56,19 @@ describe('utils/errorMapping', () => { isDuplicatePurchaseError({ code: DUPLICATE_PURCHASE_CODE, message: 'x', - } as any), + }), ).toBe(true); expect( isDuplicatePurchaseError({ code: 'duplicate-purchase', message: 'x', - } as any), + }), ).toBe(true); expect( isDuplicatePurchaseError({ code: ErrorCode.UserCancelled, message: 'x', - } as any), + }), ).toBe(false); }); @@ -77,7 +77,7 @@ describe('utils/errorMapping', () => { isRecoverableError({ code: DUPLICATE_PURCHASE_CODE, message: 'x', - } as any), + }), ).toBe(true); }); @@ -86,7 +86,7 @@ describe('utils/errorMapping', () => { getUserFriendlyErrorMessage({ code: DUPLICATE_PURCHASE_CODE, message: 'ignored', - } as any), + }), ).toBe( 'This purchase has already been processed. Try restoring purchases.', ); @@ -97,13 +97,13 @@ describe('utils/errorMapping', () => { getUserFriendlyErrorMessage({ code: ErrorCode.UserCancelled, message: 'ignored', - } as any), + }), ).toBe('Purchase cancelled'); expect( getUserFriendlyErrorMessage({ code: ErrorCode.NetworkError, message: 'ignored', - } as any), + }), ).toBe( 'Network connection error. Please check your internet connection and try again.', ); @@ -111,15 +111,15 @@ describe('utils/errorMapping', () => { getUserFriendlyErrorMessage({ code: ErrorCode.IapNotAvailable, message: 'ignored', - } as any), + }), ).toBe('In-app purchases are not available on this device'); // default fallback expect( getUserFriendlyErrorMessage({ - code: 'E_UNKNOWN_CUSTOM' as any, + code: 'E_UNKNOWN_CUSTOM', message: 'custom', - } as any), + }), ).toBe('custom'); }); diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index 0e8df700e..72af6eaed 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -109,17 +109,17 @@ describe('type-bridge utilities', () => { }, ]), }), - ) as any; + ); - expect(result.typeIOS).toBe('subscription-bundle'); - expect(result.bundledSubscriptionsIOS).toEqual([ + expect(result).toHaveProperty('typeIOS', 'subscription-bundle'); + expect(result).toHaveProperty('bundledSubscriptionsIOS', [ expect.objectContaining({ id: 'premium.monthly', subscriptionGroupId: 'premium', }), ]); - expect(result.pricingTermsIOS).toHaveLength(1); - expect(result.subscriptionOffers[0].id).toBe('intro'); + expect(result).toHaveProperty('pricingTermsIOS.length', 1); + expect(result.subscriptionOffers?.[0]?.id).toBe('intro'); expect(result).not.toHaveProperty('discountOffers'); expect(result).not.toHaveProperty('subscriptionInfoIOS'); expect(result).not.toHaveProperty('discountsIOS'); @@ -141,10 +141,10 @@ describe('type-bridge utilities', () => { }, ]), }), - ) as any; + ); expect(result.platform).toBe('android'); - expect(result.subscriptionOffers[0].offerTokenAndroid).toBe('token'); + expect(result.subscriptionOffers?.[0]?.offerTokenAndroid).toBe('token'); expect(result).not.toHaveProperty('discountOffers'); expect(result).not.toHaveProperty('subscriptionOfferDetailsAndroid'); expect(result).not.toHaveProperty('oneTimePurchaseOfferDetailsAndroid'); @@ -164,23 +164,26 @@ describe('type-bridge utilities', () => { }, ]), }), - ) as any; + ); - expect(result.discountOffers[0].offerTokenAndroid).toBe('discount-token'); + expect(result).toHaveProperty( + 'discountOffers.0.offerTokenAndroid', + 'discount-token', + ); expect(result).not.toHaveProperty('oneTimePurchaseOfferDetailsAndroid'); }); it('uses safe defaults for invalid standardized offer JSON', () => { const iosResult = convertNitroProductToProduct( product({subscriptionOffers: '{'}), - ) as any; + ); const androidResult = convertNitroProductToProduct( product({ type: 'subs', platform: 'android', subscriptionOffers: '{', }), - ) as any; + ); expect(iosResult.subscriptionOffers).toBeNull(); expect(iosResult).not.toHaveProperty('discountOffers'); @@ -200,15 +203,21 @@ describe('type-bridge utilities', () => { pricingTermsIOS: '{', bundledSubscriptionsIOS: '{', }), - ) as any; + ); expect(fallbackPlatform.platform).toBe('android'); - expect(result.typeIOS).toBe('non-consumable'); - expect(result.introductoryPricePaymentModeIOS).toBe('pay-as-you-go'); - expect(result.introductoryPriceSubscriptionPeriodIOS).toBe('day'); - expect(result.subscriptionPeriodUnitIOS).toBe('week'); - expect(result.pricingTermsIOS).toBeNull(); - expect(result.bundledSubscriptionsIOS).toBeNull(); + expect(result).toHaveProperty('typeIOS', 'non-consumable'); + expect(result).toHaveProperty( + 'introductoryPricePaymentModeIOS', + 'pay-as-you-go', + ); + expect(result).toHaveProperty( + 'introductoryPriceSubscriptionPeriodIOS', + 'day', + ); + expect(result).toHaveProperty('subscriptionPeriodUnitIOS', 'week'); + expect(result).toHaveProperty('pricingTermsIOS', null); + expect(result).toHaveProperty('bundledSubscriptionsIOS', null); expect(console.warn).toHaveBeenCalled(); }); @@ -219,12 +228,8 @@ describe('type-bridge utilities', () => { ['subscriptionSuite', 'subscription-suite'], ] as const)('normalizes iOS type %s', (nativeType, expected) => { expect( - ( - convertNitroProductToProduct( - product({typeIOS: nativeType as never}), - ) as any - ).typeIOS, - ).toBe(expected); + convertNitroProductToProduct(product({typeIOS: nativeType})), + ).toHaveProperty('typeIOS', expected); }); it('handles non-array iOS metadata and invalid Android discounts', () => { @@ -235,16 +240,22 @@ describe('type-bridge utilities', () => { introductoryPricePaymentModeIOS: 'payUpFront' as never, introductoryPriceSubscriptionPeriodIOS: 'invalid' as never, }), - ) as any; + ); const android = convertNitroProductToProduct( product({platform: 'android', discountOffers: '{'}), - ) as any; + ); - expect(ios.pricingTermsIOS).toBeNull(); - expect(ios.bundledSubscriptionsIOS).toBeNull(); - expect(ios.introductoryPricePaymentModeIOS).toBe('pay-up-front'); - expect(ios.introductoryPriceSubscriptionPeriodIOS).toBe('empty'); - expect(android.discountOffers).toBeNull(); + expect(ios).toHaveProperty('pricingTermsIOS', null); + expect(ios).toHaveProperty('bundledSubscriptionsIOS', null); + expect(ios).toHaveProperty( + 'introductoryPricePaymentModeIOS', + 'pay-up-front', + ); + expect(ios).toHaveProperty( + 'introductoryPriceSubscriptionPeriodIOS', + 'empty', + ); + expect(android).toHaveProperty('discountOffers', null); }); }); diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index 7e69ffd8c..1e72f0da8 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -918,7 +918,8 @@ describe('Amazon Vega adapter', () => { module.requestPurchase({ google: { skus: ['premium_monthly'], - subscriptionOffers: '[]' as any, + // @ts-expect-error direct Vega Nitro calls can carry serialized offers the adapter must accept. + subscriptionOffers: '[]', }, }), ).resolves.toEqual([ diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index c5fb782b6..3bfb3914a 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -213,7 +213,7 @@ type UseIap = { options: VerifyPurchaseProps, ) => Promise; /** - * Verify via a managed provider — currently only `iapkit` (IAPKit). The PurchaseVerificationProvider enum exposes no other provider literal today. + * Verify via a managed provider. `iapkit` (IAPKit) is the only one. * * @see {@link https://openiap.dev/docs/features/validation#verify-purchase-with-provider} */ @@ -301,13 +301,11 @@ export interface UseIapOptions { details: DeveloperProvidedBillingDetailsAndroid, ) => void; /** - * Fires when a subscription enters a billing-issue state - * (StoreKit 2 Message.billingIssue on iOS / Mac Catalyst 16.4+ and visionOS 1.0+, Purchase.isSuspended on - * Play Billing 8.1+). Not invoked on Meta Horizon or Amazon Appstore. - * - * Recommended: call deepLinkToSubscriptions on the returned purchase so - * the user can update their payment method in the platform subscription - * center. + * Fires when a subscription enters a billing-issue state (StoreKit 2 + * Message.billingIssue on iOS / Mac Catalyst 16.4+ and visionOS 1.0+, + * Purchase.isSuspended on Play Billing 8.1+); never on Meta Horizon or Amazon + * Appstore. Recommended: call deepLinkToSubscriptions so the user can update + * their payment method in the platform subscription center. */ onSubscriptionBillingIssue?: (purchase: Purchase) => void; /** @@ -538,11 +536,9 @@ export function useIAP(options?: UseIapOptions): UseIap { const finishTransaction = useCallback( async (args: MutationFinishTransactionArgs): Promise => { - // Directly delegate to root API finishTransaction without catching errors. - // This allows the root API's error handling logic to work correctly, including: - // - iOS: treating "Transaction not found" as success (already-finished transactions) - // - Proper validation and error messages for required fields - // Users should handle errors in their onPurchaseSuccess callback if needed. + // Errors propagate: the root API owns validation and error handling, + // including treating iOS "Transaction not found" as already finished. + // Callers handle failures in onPurchaseSuccess. await finishTransactionInternal(args); }, [], @@ -759,9 +755,8 @@ export function useIAP(options?: UseIapOptions): UseIap { return; } - // Android retains events emitted during connection setup in bounded - // native queues; registration flushes that backlog after Nitro is ready. - // Other platforms preserve the existing post-init listener ordering. + // Android buffers events emitted during connection setup in bounded + // native queues; registering here flushes them once Nitro is ready. registerListeners(); setConnected(true); } catch (error) { diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index eef694ae3..a71c06713 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -75,13 +75,6 @@ import { } from './utils/available-purchases'; import {getVegaIapModule, isVegaOS} from './vega'; -// ------------------------------ -// Billing Programs API (Android 8.2.0+) -// ------------------------------ - -// BillingProgramAndroid, ExternalLinkLaunchModeAndroid, and ExternalLinkTypeAndroid -// are exported from './types' (generated from the OpenIAP client spec). - // Export all types export type { RnIap, @@ -144,8 +137,6 @@ export interface EventSubscription { remove(): void; } -// ActiveSubscription and PurchaseError types are already exported via 'export * from ./types' - // Export hooks export {useIAP} from './hooks/useIAP'; export {kitApi, KitApiError} from './kit-api'; @@ -166,9 +157,6 @@ export type { StatusResponse, } from './kit-api'; -// Restore completed transactions (cross-platform) -// Development utilities removed - use type bridge functions directly if needed - // Create the RnIap HybridObject instance lazily to avoid early JSI crashes let iapRef: RnIap | null = null; let attachingPendingNativeListeners = false; @@ -197,7 +185,7 @@ export const isNitroReady = (): boolean => { * tvOS reports Platform.OS as 'ios' but has Platform.isTV = true. */ export const isTVOS = (): boolean => { - return Platform.OS === 'ios' && (Platform as any).isTV === true; + return Platform.OS === 'ios' && Platform.isTV === true; }; /** @@ -207,7 +195,7 @@ export const isTVOS = (): boolean => { export const isMacOS = (): boolean => { return ( Platform.OS === 'macos' || - (Platform.OS === 'ios' && (Platform as any).isMacCatalyst === true) + (Platform.OS === 'ios' && Platform.isMacCatalyst === true) ); }; @@ -317,7 +305,7 @@ const emitPurchaseUpdateToListeners = ( } else { RnIapConsole.error( 'Invalid purchase data received from native — productId:', - (nitroPurchase as any)?.productId ?? 'unknown', + nitroPurchase?.productId ?? 'unknown', ); } }; @@ -420,7 +408,7 @@ const promotedProductNativeHandler: NitroPromotedProductListener = ( } else { RnIapConsole.error( 'Invalid promoted product data received from native — id:', - (nitroProduct as any)?.id ?? 'unknown', + nitroProduct?.id ?? 'unknown', ); } }; @@ -592,31 +580,6 @@ export const promotedProductListenerIOS = ( }; }; -/** - * Add a listener for user choice billing events (Android only). - * Fires when a user selects alternative billing in the User Choice Billing dialog. - * - * @param listener - Function to call when user chooses alternative billing - * @returns EventSubscription with remove() method to unsubscribe - * @platform Android - * - * @example - * ```typescript - * const subscription = userChoiceBillingListenerAndroid((details) => { - * console.log('User chose alternative billing'); - * console.log('Products:', details.products); - * console.log('External transaction token received; send it to your backend without logging it.'); - * - * // Send token to backend for Google Play reporting - * void reportToGooglePlay(details.externalTransactionToken).catch((error) => { - * console.warn('Alternative billing report failed', error); - * }); - * }); - * - * // Later, remove the listener - * subscription.remove(); - * ``` - */ type NitroUserChoiceBillingListener = Parameters< RnIap['addUserChoiceBillingListenerAndroid'] >[0]; @@ -650,6 +613,31 @@ function tryAttachUserChoiceBillingNative(): void { }); } +/** + * Add a listener for user choice billing events (Android only). + * Fires when a user selects alternative billing in the User Choice Billing dialog. + * + * @param listener - Function to call when user chooses alternative billing + * @returns EventSubscription with remove() method to unsubscribe + * @platform Android + * + * @example + * ```typescript + * const subscription = userChoiceBillingListenerAndroid((details) => { + * console.log('User chose alternative billing'); + * console.log('Products:', details.products); + * console.log('External transaction token received; send it to your backend without logging it.'); + * + * // Send token to backend for Google Play reporting + * void reportToGooglePlay(details.externalTransactionToken).catch((error) => { + * console.warn('Alternative billing report failed', error); + * }); + * }); + * + * // Later, remove the listener + * subscription.remove(); + * ``` + */ export const userChoiceBillingListenerAndroid = ( listener: (details: UserChoiceBillingDetails) => void, ): EventSubscription => { @@ -695,32 +683,6 @@ export const userChoiceBillingListenerAndroid = ( }; }; -/** - * Add a listener for developer provided billing events (Android 8.3.0+). - * Fires for External Payments and Billing Choice developer billing flows. - * - * The payload includes selected products and nullable token, link, and original - * transaction fields. Billing Choice fields require Billing Library 9.1.0+. - * - * @param listener - Function to call when user chooses developer billing - * @returns EventSubscription with remove() method to unsubscribe - * @platform Android - * @since Google Play Billing Library 8.3.0+ - * - * @example - * ```typescript - * const subscription = developerProvidedBillingListenerAndroid((details) => { - * void processExternalPayment(details.products, details.linkUri) - * .then(() => details.externalTransactionToken - * ? reportToGooglePlay(details.externalTransactionToken) - * : undefined) - * .catch((error) => console.warn('Developer billing failed', error)); - * }); - * - * // Later, remove the listener - * subscription.remove(); - * ``` - */ type NitroDeveloperProvidedBillingListener = Parameters< RnIap['addDeveloperProvidedBillingListenerAndroid'] >[0]; @@ -753,6 +715,32 @@ function tryAttachDeveloperProvidedBillingNative(): void { }); } +/** + * Add a listener for developer provided billing events (Android 8.3.0+). + * Fires for External Payments and Billing Choice developer billing flows. + * + * The payload includes selected products and nullable token, link, and original + * transaction fields. Billing Choice fields require Billing Library 9.1.0+. + * + * @param listener - Function to call when user chooses developer billing + * @returns EventSubscription with remove() method to unsubscribe + * @platform Android + * @since Google Play Billing Library 8.3.0+ + * + * @example + * ```typescript + * const subscription = developerProvidedBillingListenerAndroid((details) => { + * void processExternalPayment(details.products, details.linkUri) + * .then(() => details.externalTransactionToken + * ? reportToGooglePlay(details.externalTransactionToken) + * : undefined) + * .catch((error) => console.warn('Developer billing failed', error)); + * }); + * + * // Later, remove the listener + * subscription.remove(); + * ``` + */ export const developerProvidedBillingListenerAndroid = ( listener: (details: DeveloperProvidedBillingDetailsAndroid) => void, ): EventSubscription => { @@ -778,30 +766,6 @@ export const developerProvidedBillingListenerAndroid = ( }; }; -/** - * Listen for subscription billing-issue events (cross-platform). - * - * Fires when a subscription enters a billing-issue state: - * - iOS / Mac Catalyst 16.4+ and visionOS 1.0+: via StoreKit 2 `Message.Reason.billingIssue`. - * - Android (Play Billing 8.1+): when `isSuspendedAndroid === true` is observed. - * - Horizon / iOS 17 / older platforms: never fires. - * - * Recommended UX: on fire, call `deepLinkToSubscriptions()` so the user can - * update their payment method in the platform subscription center. - * - * @param listener - Function to call with the affected Purchase - * @returns EventSubscription with remove() method to unsubscribe - * - * @example - * ```typescript - * const subscription = subscriptionBillingIssueListener((purchase) => { - * console.warn('Subscription needs attention:', purchase.productId); - * deepLinkToSubscriptions({skuAndroid: purchase.productId, packageNameAndroid: 'com.example.app'}); - * }); - * - * subscription.remove(); - * ``` - */ type NitroSubscriptionBillingIssueListener = Parameters< RnIap['addSubscriptionBillingIssueListener'] >[0]; @@ -841,6 +805,30 @@ function tryAttachSubscriptionBillingIssueNative(): void { }); } +/** + * Listen for subscription billing-issue events (cross-platform). + * + * Fires when a subscription enters a billing-issue state: + * - iOS / Mac Catalyst 16.4+ and visionOS 1.0+: via StoreKit 2 `Message.Reason.billingIssue`. + * - Android (Play Billing 8.1+): when `isSuspendedAndroid === true` is observed. + * - Horizon, Amazon, macOS, tvOS, watchOS, and iOS before 16.4: never fires. + * + * Recommended UX: on fire, call `deepLinkToSubscriptions()` so the user can + * update their payment method in the platform subscription center. + * + * @param listener - Function to call with the affected Purchase + * @returns EventSubscription with remove() method to unsubscribe + * + * @example + * ```typescript + * const subscription = subscriptionBillingIssueListener((purchase) => { + * console.warn('Subscription needs attention:', purchase.productId); + * deepLinkToSubscriptions({skuAndroid: purchase.productId, packageNameAndroid: 'com.example.app'}); + * }); + * + * subscription.remove(); + * ``` + */ export const subscriptionBillingIssueListener = ( listener: (purchase: Purchase) => void, ): EventSubscription => { @@ -958,7 +946,6 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { const subscriptionItems: ProductSubscription[] = []; converted.forEach((item) => { - // With discriminated unions, type field is now reliable if (item.type === 'in-app') { productItems.push(item); return; @@ -1157,21 +1144,17 @@ export const getStorefront: QueryField<'getStorefront'> = async () => { }; /** - * iOS only - Gets the original app transaction ID if the app was purchased from the App Store + * Get the app transaction: StoreKit's JWS-verified record of how the app was + * acquired (iOS 16+). Returns null when none is available. * @platform iOS - * @description - * This function retrieves the original app transaction information if the app was purchased - * from the App Store. Returns null if the app was not purchased (e.g., free app or TestFlight). * - * @returns {Promise} The original app transaction ID or null + * @returns {Promise} The parsed app transaction, or null * * @example * ```typescript * const appTransaction = await getAppTransactionIOS(); * if (appTransaction) { - * console.log('App was purchased, transaction ID:', appTransaction); - * } else { - * console.log('App was not purchased from App Store'); + * console.log('Original app version:', appTransaction.originalAppVersion); * } * ``` * @@ -2116,10 +2099,9 @@ export const consumePurchaseAndroid: MutationField< /** * Open the Google Play offer/promo code redemption page (Android only). - * On Google Play builds, launches the Play Store redeem page so the user can - * enter a code. Returns false on store flavors without an equivalent flow. - * Does not require the billing client to be initialized. Reconcile purchases - * when the app resumes because listener delivery depends on connection state. + * Returns false on store flavors without an equivalent flow. Needs no + * initialized billing client. Reconcile purchases when the app resumes: + * listener delivery depends on connection state. * * @returns Promise - true when the redemption page was launched * @platform Android @@ -2156,7 +2138,6 @@ export const openRedeemOfferCodeAndroid: MutationField< * * @example * ```typescript - * // Use verifyPurchase instead: * const result = await verifyPurchase({ * apple: { sku: 'premium_monthly' }, * google: { @@ -2303,10 +2284,7 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( }; /** - * Verify purchase with a specific provider (e.g., IAPKit) - * - * This function allows you to verify purchases using external verification - * services like IAPKit, which provide additional validation and security. + * Verify a purchase with an external provider such as IAPKit. * * @param options - Verification options including provider and credentials * @returns Promise resolving to provider-specific verification result @@ -2597,14 +2575,9 @@ export const deepLinkToSubscriptionsIOS = async (): Promise => { }; /** - * Get all active subscriptions with detailed information (OpenIAP compliant) - * Returns an array of active subscriptions. If subscriptionIds is not provided, - * returns all active subscriptions. Platform-specific fields are populated based - * on the current platform. - * - * On iOS, this uses the native getActiveSubscriptions method which includes - * renewalInfoIOS with details about subscription renewal status, pending - * upgrades/downgrades, and auto-renewal preferences. + * Get active subscriptions, limited to `subscriptionIds` when given. + * On iOS each result includes renewalInfoIOS: renewal status, pending + * upgrades/downgrades, and auto-renewal preference. * * @param subscriptionIds - Optional array of subscription IDs to filter by * @returns Promise - Array of active subscriptions @@ -2615,9 +2588,6 @@ export const getActiveSubscriptions: QueryField< 'getActiveSubscriptions' > = async (subscriptionIds) => { try { - // Use native getActiveSubscriptions on both platforms - // iOS: includes renewalInfoIOS with subscription lifecycle info - // Android: uses OpenIAP which calls Google Play Billing's getActiveSubscriptions const activeSubscriptions = await IAP.instance.getActiveSubscriptions( subscriptionIds ?? undefined, ); @@ -2690,9 +2660,8 @@ export const getActiveSubscriptions: QueryField< }; /** - * Check if the user has any active subscriptions (OpenIAP compliant) - * Returns true if the user has at least one active subscription, false otherwise. - * If subscriptionIds is provided, only checks for those specific subscriptions. + * Check whether the user has an active subscription, limited to + * `subscriptionIds` when given. * * @param subscriptionIds - Optional array of subscription IDs to check * @returns Promise - True if there are active subscriptions @@ -2777,8 +2746,8 @@ const normalizeProductQueryType = ( }; /** - * Enable a billing program before initConnection (Android only). - * Must be called BEFORE initConnection() to configure the BillingClient. + * Enable a billing program (Android only). Must be called before + * initConnection() to configure the BillingClient. * * @param program - The billing program to enable (external-content-link or external-offer) * @platform Android @@ -3049,10 +3018,7 @@ export const launchExternalLinkAndroid: MutationField< /** * Check if the device can present an external purchase notice sheet (iOS 17.4+). - * - * Wraps `ExternalPurchase.canPresent`, which Apple introduced in iOS 17.4. - * Note: the notice sheet itself (`presentExternalPurchaseNoticeSheetIOS`) - * still requires iOS 18.2+; only the eligibility check is available earlier. + * Wraps `ExternalPurchase.canPresent`. * * @returns Promise - true if notice sheet can be presented * @platform iOS @@ -3086,7 +3052,7 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< }; /** - * Present an external purchase notice sheet to inform users about external purchases (iOS 18.2+). + * Present an external purchase notice sheet to inform users about external purchases (iOS 17.4+). * This must be called before opening an external purchase link. * * @returns Promise - Result with action and error if any @@ -3157,7 +3123,6 @@ export const presentExternalPurchaseLinkIOS: MutationField< /** * Check if app is eligible for ExternalPurchaseCustomLink API (iOS 18.1+). - * Returns true if the app can use custom external purchase links. * * @returns Promise - true if eligible * @platform iOS @@ -3231,9 +3196,8 @@ export const getExternalPurchaseCustomLinkTokenIOS: QueryField< }; /** - * Show ExternalPurchaseCustomLink notice sheet (iOS 18.1+). - * Displays the system disclosure notice sheet for custom external purchase links. - * Call this after a deliberate customer interaction before linking out to external purchases. + * Show the system disclosure sheet for ExternalPurchaseCustomLink (iOS 18.1+). + * Call it after a deliberate customer interaction, before linking out. * * @param noticeType - Notice type: 'browser' (external purchases displayed in browser) * @returns Promise - Result with continued status and error if any diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts index 2389bfae6..8315c281f 100644 --- a/libraries/react-native-iap/src/kit-api.ts +++ b/libraries/react-native-iap/src/kit-api.ts @@ -1,14 +1,11 @@ -// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS -// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of -// `packages/mcp-server/src/kit-client.ts` so the same operations are -// reachable from both LLM tools and end-user apps without each -// duplicating the URL layout. +// Fetch wrapper for kit's `/v1` API, used by react-native-iap and expo-iap. +// It mirrors `packages/mcp-server/src/kit-client.ts`, so both share one URL +// layout. export type KitApiOptions = { apiKey: string; baseUrl?: string; - // Optional fetch override for runtimes without a global (older RN - // builds) or for injection in tests. + // For runtimes without a global fetch, or for tests. fetchImpl?: (input: string, init?: RequestInit) => Promise; /** Optional AsyncStorage-compatible persistent cache for direct client * payload reads. Cache failures never change a successful API result. */ @@ -95,11 +92,11 @@ export type KitProduct = { title: string; description?: string; baseLocale?: string; - localizations?: Array<{ + localizations?: { locale: string; title: string; description?: string; - }>; + }[]; regions?: "all" | string[]; priceAmountMicros?: number; currency?: string; @@ -154,7 +151,7 @@ export type KitMetricsResponse = { }; export type KitRevenueMetricsResponse = { - days: Array<{ + days: { day: string; currency: string; productId: string; @@ -165,7 +162,7 @@ export type KitRevenueMetricsResponse = { cancellations: number; refunds: number; revenueMicros: number; - }>; + }[]; currencies: string[]; productIds: string[]; platforms: KitProductPlatform[]; @@ -208,19 +205,19 @@ export type KitProductSyncJobResponse = { pulled: number; pushed: number; deleted?: number; - failures: Array<{ productId: string; reason: string }>; + failures: { productId: string; reason: string }[]; failuresTruncated?: boolean; - plannedWrites?: Array<{ + plannedWrites?: { productId: string; step: string; detail?: string; - }>; + }[]; plannedWritesTruncated?: boolean; - manualActions?: Array<{ + manualActions?: { productId: string; code: string; message: string; - }>; + }[]; manualActionsTruncated?: boolean; }; error?: string; @@ -246,11 +243,9 @@ type InternalRequestInit = Omit & { const DEFAULT_BASE_URL = "https://kit.openiap.dev"; -// Merge the request's internal headers with kit defaults (`accept`, -// optionally `content-type`). When `Headers` is missing — older React -// Native builds where the operator wires up `fetchImpl` without a -// `Headers` polyfill — the internal request sites use plain records, -// so a small case-insensitive merge is sufficient. +// Adds kit's defaults (`accept`, and `content-type` for a body) unless the +// request set them. Some React Native runtimes have fetch but no global +// `Headers`, so it falls back to a case-insensitive merge into a plain record. function mergeHeaders( callerHeaders: Record | undefined, hasBody: boolean, @@ -263,8 +258,6 @@ function mergeHeaders( } return merged; } - // Plain-object fallback path. Build a case-insensitive name map and - // re-emit it as a record `fetchImpl` accepts. const lower = new Map(); const setIfAbsent = (name: string, value: string) => { const key = name.toLowerCase(); @@ -310,20 +303,8 @@ export function kitApi(options: KitApiOptions) { path: string, init?: InternalRequestInit, ): Promise { - // Normalize headers without depending on a global `Headers` - // constructor: older React Native runtimes ship `fetch` (or a - // polyfill via `fetchImpl`) without exposing `Headers` globally. - // The prior implementation crashed before the first request on - // those runtimes. We use `new Headers()` when available and - // otherwise fall back to a small case-insensitive merge into a - // plain record. Either way, kit defaults only apply when the - // internal request hasn't set the same name. const headers = mergeHeaders(init?.headers, init?.body != null); - // Prepend a leading slash if `path` is missing one. Today's - // call sites all hard-code the leading "/", but normalizing here - // makes the helper safe for future additions and matches the - // already-stripped `baseUrl` (PR #124 - // (https://github.com/hyodotdev/openiap/pull/124) review). + // baseUrl has its trailing slash stripped, so the path needs a leading one. const normalizedPath = path.startsWith("/") ? path : `/${path}`; return fetchImpl(`${baseUrl}${normalizedPath}`, { ...init, @@ -336,26 +317,20 @@ export function kitApi(options: KitApiOptions) { path: string, ): Promise { const text = await response.text(); - // Empty body normalizes to null so callers expecting JSON - // (status / entitlements / list*) don't get a truthy "" - // and crash on property access. + // An empty body parses as null, not "". let parsed: unknown = null; let parseError: unknown = null; if (text) { try { parsed = JSON.parse(text); } catch (error) { - // Non-JSON body (a misconfigured proxy returning HTML, a - // CDN-injected error page, etc.) on a 2xx response would - // otherwise reach the caller as `parsed = text` and crash - // on property access via `parsed as T`. Throw a structured - // KitApiError instead so callers see a typed failure. + // A 2xx with a non-JSON body (a proxy's HTML error page, say) must + // fail as a KitApiError, not reach the caller as text typed as T. parseError = error; } } if (!response.ok) { - // Surface the raw body (text or parsed) on the error path so - // operators can read the upstream error message verbatim. + // Keep the raw body so the upstream error message stays readable. throw new KitApiError( response.status, parsed ?? text, diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index 0d293e104..adca8112a 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -1,35 +1,19 @@ import type {HybridObject} from 'react-native-nitro-modules'; -// ╔══════════════════════════════════════════════════════════════════════════╗ -// ║ NITRO MODULE CONSTRAINTS ║ -// ╠══════════════════════════════════════════════════════════════════════════╣ -// ║ Nitro Modules (react-native-nitro-modules) has specific limitations ║ -// ║ when generating C++/Swift/Kotlin bridge code from TypeScript types: ║ -// ║ ║ -// ║ 1. UNION TYPES REQUIRE 2+ VALUES ║ -// ║ - Single-value unions like `type Foo = 'bar'` cause codegen errors ║ -// ║ - Error: "String literal 'x' cannot be represented in C++ because ║ -// ║ it is ambiguous between a string and a discriminating union enum" ║ -// ║ - Solution: Add a fallback value (e.g., 'unspecified') to make 2+ ║ -// ║ ║ -// ║ 2. TYPES MUST BE DEFINED IN THIS FILE OR IMPORTED AS `type` ║ -// ║ - Nitro codegen reads this file to generate native bridge code ║ -// ║ - Interface types from types.ts can be imported and used directly ║ -// ║ - Union types with 2+ values can be imported from types.ts ║ -// ║ - Single-value unions must be redefined locally with extra values ║ -// ║ ║ -// ║ 3. WRITE `null` FIRST IN NULLABLE UNIONS OF BOOLEANS AND ENUM ARRAYS ║ -// ║ - Use `null | boolean`, not `boolean | null` (same for enum arrays) ║ -// ║ - Since nitrogen 0.36 variant operands keep source order when their ║ -// ║ "looseness" ties (boolean/enum-array tie with null), so null-last ║ -// ║ would rename generated types (Variant_NullType_Bool → ║ -// ║ Variant_Bool_NullType) and break hand-written Swift/Kotlin ║ -// ╚══════════════════════════════════════════════════════════════════════════╝ - -// NOTE: This Nitro spec re-exports types from the generated schema (src/types.ts) -// via type aliases to avoid duplicating structure. Nitro's codegen expects the -// canonical `Nitro*` names defined here, so we keep the aliases rather than -// removing the types entirely. +// Nitro codegen rules for this file: +// 1. A string-literal union needs 2+ values. `type Foo = 'bar'` fails with +// "String literal 'x' cannot be represented in C++ because it is ambiguous +// between a string and a discriminating union enum"; add a fallback such as +// 'unspecified'. +// 2. Define types here or import them with `import type`. Interfaces and +// 2+-value unions import from types.ts; single-value unions are redefined here. +// 3. Write `null` first in nullable boolean and enum-array unions (`null | boolean`). +// nitrogen 0.36+ keeps source order when operands tie on "looseness", so +// null-last renames the generated type (Variant_NullType_Bool → +// Variant_Bool_NullType) and breaks hand-written Swift/Kotlin. + +// Nitro codegen needs the `Nitro*` names defined here, so they alias the +// generated types in src/types.ts instead of copying their structure. import type { ActiveSubscription, AdvancedCommerceInfoIOS, @@ -38,8 +22,7 @@ import type { InitConnectionConfig, ExternalPurchaseCustomLinkNoticeResultIOS, ExternalPurchaseCustomLinkTokenResultIOS, - // ExternalPurchaseCustomLinkTokenTypeIOS has 2 values ('acquisition' | 'services') - // so it can be imported directly from types.ts + // Two values ('acquisition' | 'services'), so it imports directly. ExternalPurchaseCustomLinkTokenTypeIOS, ExternalPurchaseLinkResultIOS, ExternalPurchaseNoticeResultIOS, @@ -73,23 +56,15 @@ import type { // ╔══════════════════════════════════════════════════════════════════════════╗ // ║ LOCAL TYPE DEFINITIONS FOR NITRO ║ -// ╠══════════════════════════════════════════════════════════════════════════╣ -// ║ Types below are defined locally because: ║ -// ║ - GQL-generated type has only 1 value (Nitro requires 2+), OR ║ -// ║ - Nitro codegen needs the type defined in this file for bridge gen ║ // ╚══════════════════════════════════════════════════════════════════════════╝ -// ExternalPurchaseCustomLinkNoticeTypeIOS (iOS 18.1+) -// GQL type: 'browser' (1 value) → Nitro requires 2+ values -// Added 'unspecified' as fallback to satisfy Nitro constraint +// iOS 18.1+. The GQL type has only 'browser'; 'unspecified' satisfies rule 1. export type ExternalPurchaseCustomLinkNoticeTypeIOS = 'browser' | 'unspecified'; -// Platform identifier for cross-platform purchase/product data -// Defined locally for Nitro codegen (not in GQL schema) +// Not in the GQL schema. export type IapPlatform = 'ios' | 'android'; -// IAPKit purchase state enum for receipt verification -// Defined locally for Nitro codegen (IAPKit-specific, not in GQL schema) +// IAPKit receipt-verification state; not in the GQL schema. export type IapkitPurchaseState = | 'entitled' | 'pending-acknowledgment' @@ -103,18 +78,15 @@ export type IapkitPurchaseState = export type IapkitClientPayloadFormat = 'toml' | 'json' | 'text'; -// Store identifier for purchase origin -// Defined locally for Nitro codegen (not in GQL schema) +// Store a purchase came from; not in the GQL schema. export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon' | 'amazon'; -// Purchase verification provider selection -// Defined locally for Nitro codegen (not in GQL schema) +// Not in the GQL schema. export type PurchaseVerificationProvider = 'iapkit' | 'none'; -// Billing Programs API (Android) -// GQL type exists but defined locally for Nitro codegen consistency -// Android 8.2.0+, 8.3.0+ for external-payments, 9.1.0+ for billing-choice, -// 7.0+ for user-choice-billing +// Redefined here for codegen consistency, though the GQL type exists. +// Android 8.2.0+; external-payments 8.3.0+, billing-choice 9.1.0+, +// user-choice-billing 7.0+. export type BillingProgramAndroid = | 'unspecified' | 'external-content-link' @@ -140,22 +112,19 @@ export type InAppMessageCategoryAndroid = export type InAppMessageResponseCodeAndroid = 'no-action-needed' | 'subscription-status-updated'; -// Developer Billing Launch Mode (Android 8.3.0+) -// Defined locally for Nitro codegen +// Android 8.3.0+ export type DeveloperBillingLaunchModeAndroid = | 'unspecified' | 'launch-in-external-browser-or-app' | 'caller-will-launch-link'; -// External Link Launch Mode (Android 8.2.0+) -// Defined locally for Nitro codegen +// Android 8.2.0+ export type ExternalLinkLaunchModeAndroid = | 'unspecified' | 'launch-in-external-browser-or-app' | 'caller-will-launch-link'; -// External Link Type (Android 8.2.0+) -// Defined locally for Nitro codegen +// Android 8.2.0+ export type ExternalLinkTypeAndroid = 'unspecified' | 'link-to-digital-content-offer' | 'link-to-app-download'; @@ -223,9 +192,7 @@ export interface NitroRequestPurchaseIos { */ compactJWS?: RequestSubscriptionIosProps['compactJWS']; /** - * JWS promotional offer (iOS 15+, WWDC 2025). - * New signature format using compact JWS string for promotional offers. - * Back-deployed to iOS 15. + * Promotional offer signed as a compact JWS (WWDC 2025, back-deployed to iOS 15). * @platform iOS */ promotionalOfferJWS?: PromotionalOfferJwsInputIOS | null; @@ -623,7 +590,7 @@ export interface NitroActiveSubscription { expirationDateIOS?: ActiveSubscription['expirationDateIOS']; environmentIOS?: ActiveSubscription['environmentIOS']; daysUntilExpirationIOS?: ActiveSubscription['daysUntilExpirationIOS']; - renewalInfoIOS?: NitroRenewalInfoIOS | null; // 🆕 Key field for upgrade/downgrade detection + renewalInfoIOS?: NitroRenewalInfoIOS | null; // Detects upgrades and downgrades // Android specific fields autoRenewingAndroid?: ActiveSubscription['autoRenewingAndroid']; basePlanIdAndroid?: ActiveSubscription['basePlanIdAndroid']; @@ -696,11 +663,8 @@ export interface NitroProduct { subscriptionPeriodAndroid?: string | null; freeTrialPeriodAndroid?: string | null; /** - * Product-level status code indicating fetch result (Android 8.0+) - * OK = product fetched successfully - * NOT_FOUND = SKU doesn't exist - * NO_OFFERS_AVAILABLE = user not eligible for any offers - * Available in Google Play Billing Library 8.0.0+ + * Product fetch status (Play Billing 8.0.0+): OK, NOT_FOUND (SKU doesn't + * exist), or NO_OFFERS_AVAILABLE (user not eligible for any offers). */ productStatusAndroid?: string | null; } @@ -741,11 +705,10 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { // Purchase methods (unified) /** - * Request a purchase (unified method for both platforms) - * ⚠️ Important: This is an event-based operation, not promise-based. - * Listen for events through purchaseUpdatedListener or purchaseErrorListener. + * Request a purchase (unified method for both platforms). + * Results arrive through purchaseUpdatedListener or purchaseErrorListener. * @param request - Platform-specific purchase request parameters - * @returns Promise - Always returns void, listen for events instead + * @returns The dispatched purchase payload; the outcome arrives through the listeners */ requestPurchase( request: NitroPurchaseRequest, @@ -944,18 +907,11 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { isEligibleForIntroOfferIOS(groupID: string): Promise; /** - * Get receipt data (iOS only) - * - * ⚠️ **IMPORTANT**: iOS receipts are cumulative and contain ALL transactions for the app, - * not just the most recent one. The receipt data does not change between purchases. + * Get the App Store receipt (iOS only). * - * **For individual purchase validation, use `getTransactionJwsIOS(productId)` instead.** - * - * This returns the App Store Receipt, which: - * - Contains all purchase history for the app - * - Does not update immediately after finishTransaction() - * - May be unavailable immediately after purchase (throws purchase-verification-failed error) - * - Requires parsing to extract specific transactions + * The receipt is cumulative: it holds every transaction for the app, does not + * update immediately after finishTransaction(), and must be parsed to find one + * transaction. To validate a single purchase, use `getTransactionJwsIOS(productId)`. * * @returns Promise - Base64 encoded receipt data containing all app transactions * @throws {Error} purchase-verification-failed if receipt is not available (e.g., immediately after purchase) @@ -965,12 +921,8 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { getReceiptDataIOS(): Promise; /** - * Request a refreshed receipt from the App Store (iOS only) - * - * This calls syncIOS() to refresh the receipt from Apple's servers, then returns it. - * - * ⚠️ **IMPORTANT**: iOS receipts are cumulative and contain ALL transactions. - * For individual purchase validation, use `getTransactionJwsIOS(productId)` instead. + * Refresh the receipt through syncIOS(), then return it (iOS only). + * Like getReceiptDataIOS(), it holds every transaction for the app. * * @returns Promise - Updated Base64 encoded receipt data containing all app transactions * @platform iOS @@ -987,18 +939,9 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { isTransactionVerifiedIOS(sku: string): Promise; /** - * Get transaction JWS (JSON Web Signature) representation for a specific product (iOS only) - * - * ✅ **RECOMMENDED** for validating individual purchases with your backend. - * - * This returns a unique, cryptographically signed token for the specific transaction, - * unlike `getReceiptDataIOS()` which returns ALL transactions. - * - * Benefits: - * - Contains ONLY the requested transaction (not all historical purchases) - * - Cryptographically signed by Apple (can be verified) - * - Available immediately after purchase - * - Simpler to validate on your backend + * Get the JWS for one product's transaction (iOS only). Recommended for backend + * validation: unlike getReceiptDataIOS() it holds only this transaction, is + * signed by Apple, and is available immediately after purchase. * * @param sku - The product SKU/ID to get the transaction JWS for * @returns Promise - JWS string for the transaction, or null if not found @@ -1025,11 +968,7 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { >; /** - * Verify purchase with a specific provider (e.g., IAPKit) - * - * This function allows you to verify purchases using external verification - * services like IAPKit, which provide additional validation and security. - * + * Verify a purchase with an external provider such as IAPKit. * @param params - Verification options including provider and credentials * @returns Promise - Provider-specific verification result */ @@ -1100,15 +1039,11 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { ): void; /** - * Add a listener for subscription billing-issue events (cross-platform). - * - * Fires when a user's active subscription enters a state that needs attention - * (payment method failed, card expired, etc.). Unifies: - * - StoreKit 2 `Message.Reason.billingIssue` (iOS / Mac Catalyst 16.4+, visionOS 1.0+) - * - Google Play Billing `Purchase.isSuspended` (Play Billing 8.1+) - * - * NOT fired on Meta Horizon (Billing 7.0 compat SDK lacks the suspended signal). - * + * Add a listener for active subscriptions that need payment attention (failed + * payment method, expired card). Sources: StoreKit 2 `Message.Reason.billingIssue` + * (iOS / Mac Catalyst 16.4+, visionOS 1.0+) and Play Billing 8.1+ + * `Purchase.isSuspended`. Never fires on Meta Horizon: its Billing 7.0 compat + * SDK has no suspended signal. * @param listener - Called with the affected Purchase */ addSubscriptionBillingIssueListener( @@ -1127,8 +1062,8 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { // ╚════════════════════════════════════════════════════════════════════════╝ /** - * Enable a billing program before initConnection (Android only). - * Must be called BEFORE initConnection() to configure the BillingClient. + * Enable a billing program (Android only). Must be called before + * initConnection() to configure the BillingClient. * * @param program - The billing program to enable * @platform Android @@ -1224,7 +1159,7 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { // ╚════════════════════════════════════════════════════════════════════════╝ /** - * Check if the device can present an external purchase notice sheet (iOS 18.2+). + * Check if the device can present an external purchase notice sheet (iOS 17.4+). * * @returns Promise - true if notice sheet can be presented * @platform iOS @@ -1232,7 +1167,7 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { canPresentExternalPurchaseNoticeIOS(): Promise; /** - * Present an external purchase notice sheet to inform users about external purchases (iOS 18.2+). + * Present an external purchase notice sheet to inform users about external purchases (iOS 17.4+). * This must be called before opening an external purchase link. * * @returns Promise - Result with action and error if any diff --git a/libraries/react-native-iap/src/utils/__tests__/errorMapping.test.ts b/libraries/react-native-iap/src/utils/__tests__/errorMapping.test.ts index be87ca7ab..5de5eaaf5 100644 --- a/libraries/react-native-iap/src/utils/__tests__/errorMapping.test.ts +++ b/libraries/react-native-iap/src/utils/__tests__/errorMapping.test.ts @@ -145,7 +145,7 @@ describe('errorMapping', () => { ErrorCodeUtils.isValidForPlatform(ErrorCode.UserCancelled, 'android'), ).toBe(true); expect( - ErrorCodeUtils.isValidForPlatform(ErrorCode.Unknown as any, 'ios'), + ErrorCodeUtils.isValidForPlatform(ErrorCode.Unknown, 'ios'), ).toBe(true); }); }); diff --git a/libraries/react-native-iap/src/utils/debug.ts b/libraries/react-native-iap/src/utils/debug.ts index d274bc313..df1caef6b 100644 --- a/libraries/react-native-iap/src/utils/debug.ts +++ b/libraries/react-native-iap/src/utils/debug.ts @@ -1,14 +1,10 @@ /** - * Debug logger for React Native IAP - * Only logs when explicitly enabled for library development - * Silent for all library users (even in their dev mode) + * Debug logger for React Native IAP. + * log/debug/info print only when library developers set RN_IAP_DEV_MODE=true, + * so apps stay silent even in their dev builds. warn/error always print. */ -// Check if we're in library development mode -// This will be false for library users, even in their dev environment const isLibraryDevelopment = () => { - // Only show logs if explicitly enabled via environment variable - // Library developers can set: RN_IAP_DEV_MODE=true const g = globalThis as { process?: {env?: Record}; RN_IAP_DEV_MODE?: boolean; @@ -23,23 +19,19 @@ export const RnIapConsole = { if (isLibraryDevelopment()) { console.log('[RN-IAP]', ...args); } - // Silent for library users }, debug: (...args: any[]) => { if (isLibraryDevelopment()) { console.debug('[RN-IAP Debug]', ...args); } - // Silent for library users }, warn: (...args: any[]) => { - // Warnings are always shown console.warn('[RN-IAP]', ...args); }, error: (...args: any[]) => { - // Errors are always shown console.error('[RN-IAP]', ...args); }, @@ -47,6 +39,5 @@ export const RnIapConsole = { if (isLibraryDevelopment()) { console.info('[RN-IAP]', ...args); } - // Silent for library users }, }; diff --git a/libraries/react-native-iap/src/utils/errorMapping.ts b/libraries/react-native-iap/src/utils/errorMapping.ts index 038ce63e5..f21a8702e 100644 --- a/libraries/react-native-iap/src/utils/errorMapping.ts +++ b/libraries/react-native-iap/src/utils/errorMapping.ts @@ -10,10 +10,7 @@ import { type SubResponseCodeAndroid, } from '../types'; -/** - * Error code for duplicate purchase events detected on iOS. - * Now part of the official OpenIAP ErrorCode enum. - */ +/** Error code for duplicate purchase events detected on iOS. */ export const DUPLICATE_PURCHASE_CODE = ErrorCode.DuplicatePurchase; const ERROR_CODE_ALIASES: Record = { diff --git a/libraries/react-native-iap/src/utils/type-bridge.ts b/libraries/react-native-iap/src/utils/type-bridge.ts index 618c15d65..0af58e745 100644 --- a/libraries/react-native-iap/src/utils/type-bridge.ts +++ b/libraries/react-native-iap/src/utils/type-bridge.ts @@ -20,6 +20,7 @@ import type { PurchaseState, SubscriptionPeriodIOS, Product, + ProductOrSubscription, ProductSubscription, Purchase, PurchaseAndroid, @@ -372,7 +373,11 @@ export function convertNitroProductToProduct( */ export function convertProductToProductSubscription( product: Product, -): ProductSubscription { +): ProductSubscription; +// The public overload keeps the shipped cast contract; an in-app input is copied through with a warning. +export function convertProductToProductSubscription( + product: ProductOrSubscription, +): ProductOrSubscription { if (product.type !== PRODUCT_TYPE_SUBS) { RnIapConsole.warn( 'Converting non-subscription product to ProductSubscription:', @@ -380,7 +385,7 @@ export function convertProductToProductSubscription( ); } - return {...(product as any)}; + return {...product}; } /** diff --git a/packages/apple/Example/OpenIapExample/Info.plist.example b/packages/apple/Example/OpenIapExample/Info.plist.example index 5f575be8c..eec5fdbeb 100644 --- a/packages/apple/Example/OpenIapExample/Info.plist.example +++ b/packages/apple/Example/OpenIapExample/Info.plist.example @@ -5,5 +5,16 @@ IAPKIT_API_KEY openiap-kit_pk_your_publishable_key_here + IAPKIT_BASE_URL + + + NSAppTransportSecurity + + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + diff --git a/packages/apple/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift b/packages/apple/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift index 5c9c7ac0b..41a473298 100644 --- a/packages/apple/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift @@ -130,7 +130,7 @@ struct AlternativeBillingScreen: View { .autocapitalization(.none) .keyboardType(.URL) - Text("Tap Purchase on any product below. The ExternalPurchase API (iOS 18.2+) will show Apple's notice sheet before opening this URL.") + Text("Tap Purchase on any product below. Apple's notice sheet (iOS 17.4+) appears before this URL opens.") .font(.caption) .foregroundColor(.secondary) } @@ -224,7 +224,7 @@ struct AlternativeBillingScreen: View { ) InstructionRow( number: "3", - text: "Apple's notice sheet appears (iOS 18.2+)" + text: "Apple's notice sheet appears (iOS 17.4+)" ) InstructionRow( number: "4", @@ -242,7 +242,7 @@ struct AlternativeBillingScreen: View { .fontWeight(.semibold) .foregroundColor(AppColors.warning) - Text("• iOS 18.2+ required for ExternalPurchase API\n• Apple's official alternative billing compliance\n• Notice sheet shows App Store warning\n• Purchase completes on external website\n• Deep link needed to return to app") + Text("• iOS 17.4+ required for the notice sheet\n• Apple's official alternative billing compliance\n• Notice sheet shows App Store warning\n• Purchase completes on external website\n• Deep link needed to return to app") .font(.caption) .foregroundColor(.secondary) } @@ -318,23 +318,23 @@ struct AlternativeBillingScreen: View { } } - // MARK: - Purchase Flow with Alternative Billing (iOS 18.2+) + // MARK: - Purchase Flow with Alternative Billing private func purchaseProduct(_ product: OpenIapProduct) { print("🛒 [AlternativeBilling] Starting alternative billing purchase for: \(product.id)") print("🌐 [AlternativeBilling] External URL: \(externalUrl)") - if #available(iOS 18.2, *) { + if #available(iOS 17.4, *) { Task { await testExternalPurchaseFlow() } } else { - errorMessage = "Alternative billing with ExternalPurchase API requires iOS 18.2 or later" + errorMessage = "The external purchase notice sheet requires iOS 17.4 or later" showError = true } } - // MARK: - External Purchase Flow (iOS 18.2+) + // MARK: - External Purchase Flow - @available(iOS 18.2, *) + @available(iOS 17.4, *) private func testExternalPurchaseFlow() async { print("🔷 [AlternativeBilling] Testing external purchase flow...") diff --git a/packages/apple/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift b/packages/apple/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift index 83d2a7be2..5f3372ee3 100644 --- a/packages/apple/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/PurchaseFlowScreen.swift @@ -37,6 +37,12 @@ struct PurchaseFlowScreen: View { Bundle.main.object(forInfoDictionaryKey: "IAPKIT_API_KEY") as? String } + // Local IAPKit origin (set in scheme or Info.plist); nil uses the hosted server. + private var iapkitBaseUrl: String? { + ProcessInfo.processInfo.environment["IAPKIT_BASE_URL"] ?? + Bundle.main.object(forInfoDictionaryKey: "IAPKIT_BASE_URL") as? String + } + // Product IDs configured in App Store Connect private let productIds: [String] = [ "dev.hyo.martie.10bulbs", @@ -497,6 +503,7 @@ struct PurchaseFlowScreen: View { apple: RequestVerifyPurchaseWithIapkitAppleProps( jws: jws ), + baseUrl: iapkitBaseUrl, google: nil, // Client payload is public configuration, never entitlement authority or secrets. includeClientPayload: true diff --git a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift index 1be5e4ba0..485bc9ba0 100644 --- a/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift @@ -662,6 +662,7 @@ struct SubscriptionFlowScreen: View { apple: RequestVerifyPurchaseWithIapkitAppleProps( jws: jws ), + baseUrl: iapkitBaseUrl, google: nil ), provider: .iapkit diff --git a/packages/cli/README.md b/packages/cli/README.md index a62de588e..f137ff0a2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -72,12 +72,12 @@ the same files itself; running the CLI makes these particular checks consistent across developers, agents, and CI. Review a finding, fix its cause, and rerun the same command. Pin the CLI version in CI to keep the rule set consistent. -| Need | Use | -| --- | --- | -| Decide where an app, paywall, backend, or data service connects | `init --role …`, or the [role guide](https://openiap.dev/commerce-protocol/ecosystem) directly | -| Catch supported local configuration mistakes | `doctor --json` in the target app directory | -| See verification, ownership, access, and delivery execute | The [runnable Commerce Protocol example](https://github.com/hyodotdev/openiap-commerce-protocol-example) | -| Verify a provider's protocol behavior | The [conformance tools](https://openiap.dev/commerce-protocol/conformance) and tests for its declared profiles | +| Need | Use | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Decide where an app, paywall, backend, or data service connects | `init --role …`, or the [role guide](https://openiap.dev/commerce-protocol/ecosystem) directly | +| Catch supported local configuration mistakes | `doctor --json` in the target app directory | +| See verification, ownership, access, and delivery execute | The [runnable Commerce Protocol example](https://github.com/hyodotdev/openiap-commerce-protocol-example) | +| Verify a provider's protocol behavior | The [conformance tools](https://openiap.dev/commerce-protocol/conformance) and tests for its declared profiles | The CLI is not needed to run the example or use OpenIAP SDKs. The example is fixture-backed teaching code; neither its tests nor a clean `doctor` report @@ -121,10 +121,11 @@ Most of these produce no error message that says what is actually wrong. | Check | Level | What goes wrong without it | | ----------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `android-store-flavor-mismatch` | error | A half-finished regeneration links one store while the flags select another. | -| `android-store-flavor-conflict` | error | Both store flags are true. Gradle also refuses this; the doctor sees it before a build. | -| `android-store-not-play` | warning | The build targets Horizon or Amazon, so Play billing cannot connect on a Play device. | -| `android-horizon-app-id-missing` | warning | Horizon is selected but no manifest declares an app id. | +| `android-store-flavor-mismatch` | error | A half-finished regeneration links one store while gradle.properties pins another. | +| `android-store-flavor-conflict` | error | The `openiapStore` pin and the legacy flags disagree, or both legacy flags are true. Gradle also refuses this; the doctor sees it before a build. | +| `android-store-unknown` | error | `openiapStore` names something that is not a store, so Gradle will refuse the build. | +| `android-store-not-play` | warning | The project is pinned to Horizon, Amazon, or no store at all, so Play billing cannot connect on a Play device. Without a pin the task flavor or the connected debug device picks the store per build. | +| `android-horizon-app-id-missing` | warning | Horizon is pinned but no manifest declares an app id. | | `iapkit-secret-key-in-client` | error | A secret key is on a name that reaches the app bundle. | | `iapkit-secret-key-in-env` | warning | A secret key is in an env file on a name nothing here proves is inlined. | | `iapkit-secret-key-in-config` | warning | Executable app configuration contains a secret, but its presence in the app bundle is unproven. | diff --git a/packages/cli/src/checks.mjs b/packages/cli/src/checks.mjs index 782bfc1b5..17500f89c 100644 --- a/packages/cli/src/checks.mjs +++ b/packages/cli/src/checks.mjs @@ -62,12 +62,31 @@ const APP_BUILD_FILES = [ "android/app/build.gradle.kts", ]; +// Same table as packages/google/gradle/openiap-store.gradle. +export const STORE_ALIASES = { + play: "play", + google: "play", + gplay: "play", + googleplay: "play", + "google-play": "play", + gms: "play", + horizon: "horizon", + meta: "horizon", + quest: "horizon", + amazon: "amazon", + fire: "amazon", + fireos: "amazon", + "fire-os": "amazon", + none: "none", + auto: "auto", +}; + /** - * A generated Android project is written in one pass, so its store flag and - * its flavor literal always agree when they are fresh. A disagreement means a - * half-finished regeneration, and the app links a store the device may not run. + * Reports the store gradle.properties pins (openiapStore or a legacy flag) and + * a leftover platform strategy in app/build.gradle that disagrees with it. + * Without a pin Gradle picks the store per build, which no file records. */ -export function androidStoreChecks(root) { +export function androidStoreChecks(root, framework) { const propertiesText = read(root, "android/gradle.properties"); const app = readFirst(root, APP_BUILD_FILES); // Either file alone still proves which store the build links. @@ -81,8 +100,61 @@ export function androidStoreChecks(root) { ); const horizon = enabled("horizonEnabled"); const fireOs = enabled("fireOsEnabled"); + const storeEntry = properties?.get("openiapStore"); + const storeValue = storeEntry?.value.trim().toLowerCase() ?? ""; + // openIapNormalizeStore: a blank value is absent, and only an alias is a store. + const explicit = + storeValue === "" + ? null + : Object.hasOwn(STORE_ALIASES, storeValue) + ? STORE_ALIASES[storeValue] + : "unknown"; + const pinned = + explicit !== null && explicit !== "auto" && explicit !== "unknown"; + // The legacy keys still pin, with a deprecation warning: openiapPlatform=none + // opts out, and fireOsEnabled or horizonEnabled picks a store. + const platformEntry = properties?.get("openiapPlatform"); + const platformValue = platformEntry?.value.trim().toLowerCase() ?? ""; + const optOut = platformEntry !== undefined && platformValue === "none"; + const [legacyName, legacy] = optOut + ? ["openiapPlatform", "none"] + : fireOs + ? ["fireOsEnabled", "amazon"] + : horizon + ? ["horizonEnabled", "horizon"] + : [null, null]; + const legacyEntry = legacy ? properties.get(legacyName) : undefined; + const legacyKey = legacy + ? `${legacyName}=${oneLine(legacyEntry.value)}` + : null; const findings = []; + // Each refusal is a GradleException in openIapExplicitStore: the build stops + // before it links anything, so none of these projects is reported as a store. + if (explicit === "unknown") { + findings.push( + finding( + "android-store-unknown", + "error", + "android/gradle.properties", + `openiapStore=${oneLine(storeEntry.value)} is not a store.`, + "Use play, horizon, amazon, or auto; none is the Flutter opt-out.", + { line: storeEntry?.line }, + ), + ); + } + if (platformEntry && !optOut) { + findings.push( + finding( + "android-store-unknown", + "error", + "android/gradle.properties", + `openiapPlatform=${oneLine(platformEntry.value)} only supports the opt-out value none.`, + "Use openiapStore to pick a store.", + { line: platformEntry?.line }, + ), + ); + } if (horizon && fireOs) { findings.push( finding( @@ -94,6 +166,52 @@ export function androidStoreChecks(root) { { line: properties.get("horizonEnabled")?.line }, ), ); + } else if (optOut && (horizon || fireOs)) { + findings.push( + finding( + "android-store-flavor-conflict", + "error", + "android/gradle.properties", + `openiapPlatform=none conflicts with ${fireOs ? "fireOsEnabled" : "horizonEnabled"}=true.`, + "Drop the legacy store flag, or drop the opt-out.", + { line: platformEntry?.line }, + ), + ); + } else if (pinned && legacy && legacy !== explicit) { + findings.push( + finding( + "android-store-flavor-conflict", + "error", + "android/gradle.properties", + `openiapStore=${explicit} disagrees with ${legacyKey}.`, + optOut + ? "Keep openiapStore; openiapPlatform is the legacy spelling of the opt-out." + : "Keep the openiapStore pin and delete the legacy flags.", + { line: (optOut ? platformEntry : storeEntry)?.line }, + ), + ); + } + + // What gradle.properties pins every build of this checkout to, if anything. + // -P and ORG_GRADLE_PROJECT_ pins are invisible here. + const decided = findings.length > 0 ? null : pinned ? explicit : legacy; + const pinKey = pinned + ? `openiapStore=${oneLine(storeEntry.value)}` + : legacyKey; + const pinLine = (pinned ? storeEntry : legacyEntry)?.line; + // Only flutter_inapp_purchase compiles a no-op Android implementation; every + // other wrapper fails the build on this value. + if (decided === "none" && framework && framework !== "flutter") { + findings.push( + finding( + "android-store-unknown", + "error", + "android/gradle.properties", + `${pinKey} is not supported by ${framework}.`, + "Remove the opt-out; only flutter_inapp_purchase builds without an Android store SDK.", + { line: pinLine }, + ), + ); } // Gradle comments hold disabled configuration; reading them reports fiction. @@ -112,29 +230,25 @@ export function androidStoreChecks(root) { const linksNonPlay = stores.includes("play") ? undefined : stores.find((one) => one !== "play"); - // Build types may legitimately link different stores, so a mismatch is not - // about how many are declared: it is that none of them is the one the flags - // selected, which only a half-finished regeneration produces. const hasStoreFlags = properties?.has("fireOsEnabled") || properties?.has("horizonEnabled"); - const selects = hasStoreFlags - ? fireOs - ? "amazon" - : horizon - ? "horizon" - : "play" - : null; + // Flags both false still state Play, which a leftover literal can contradict. + const selects = findings.length + ? null + : (decided ?? (hasStoreFlags ? "play" : null)); // A computed flavor may well resolve to the selected store, so a mismatch is // only provable when every strategy names a store and none of them is it. + // `none` links no store, so a leftover literal is inert rather than wrong. const missing = selects !== null && + selects !== "none" && !computed && stores.length > 0 && !stores.includes(selects); const declared = stores.length === 1 && !computed ? stores[0] : null; const line = strategies[0]?.number; - // With both flags true `selected` is this tool's own tiebreak, not something + // With both flags true `selects` is this tool's own tiebreak, not something // gradle.properties states, and the conflict finding already covers it. if (missing && !(horizon && fireOs)) { findings.push( @@ -149,24 +263,36 @@ export function androidStoreChecks(root) { ); } - const store = linksNonPlay ?? declared ?? selects; - if (store && store !== "play") { - const evidence = - declared || linksNonPlay ? app.file : "android/gradle.properties"; - const enabledFlag = fireOs ? "fireOsEnabled" : "horizonEnabled"; + // The wrappers link the store the resolver picks, so a pin decides it and a + // literal left in the app file only records what an older project linked. + const store = decided ?? linksNonPlay ?? declared; + const refused = findings.some((one) => one.level === "error"); + if (!refused && store && store !== "play") { + let message; + let fix; + if (!decided) { + message = `This Android project last linked the ${store} store; with no pin in gradle.properties, each build resolves its own.`; + fix = `The ${store} strategy left in ${app.file} no longer selects the store; remove it, and pin with openiapStore only where a build must target ${store}.`; + } else if (store === "none") { + message = `This Android project links no store SDK (${pinKey}).`; + fix = `Remove ${pinKey} before testing purchases on a device.`; + } else { + message = computed + ? `gradle.properties selects the ${store} store (${pinKey}), and the build computes its flavor from it.` + : `This Android project is pinned to the ${store} store (${pinKey}).`; + fix = `Google Play billing will not connect from this build. Remove ${pinned ? "the openiapStore pin" : `${pinKey}, a deprecated pin,`} before testing on a Play device; without a pin, the task flavor or the connected debug device selects the store.`; + } findings.push( finding( "android-store-not-play", "warning", - evidence, - computed && !linksNonPlay - ? `gradle.properties selects the ${store} store, and the build computes its flavor from it.` - : `This Android project is built for the ${store} store.`, - `Google Play billing will not connect from this build. Regenerate without the ${store} flags before testing on a Play device.`, + decided ? "android/gradle.properties" : app.file, + message, + fix, { - line: linksNonPlay - ? strategies.find((one) => one.match[1] === linksNonPlay)?.number - : properties?.get(enabledFlag)?.line, + line: decided + ? pinLine + : strategies.find((one) => one.match[1] === store)?.number, actual: store, }, ), diff --git a/packages/cli/src/doctor.mjs b/packages/cli/src/doctor.mjs index b1c28affe..ac548779b 100644 --- a/packages/cli/src/doctor.mjs +++ b/packages/cli/src/doctor.mjs @@ -51,8 +51,8 @@ function skippedChecks(root, framework) { skipped.push("Android store flavor: no android/ project was found here."); } - const iosDirs = listDir(root, "ios").filter((one) => - !isGeneratedDir(one) && isDirectory(root, `ios/${one}`), + const iosDirs = listDir(root, "ios").filter( + (one) => !isGeneratedDir(one) && isDirectory(root, `ios/${one}`), ); const iosPlists = [ "ios/Info.plist", @@ -138,7 +138,7 @@ export function doctor(root) { ); } findings.push( - ...androidStoreChecks(root), + ...androidStoreChecks(root, framework), ...secretKeyChecks(root, framework), ...envNameChecks(root, framework), ...baseUrlChecks(root, framework), diff --git a/packages/cli/src/findings.mjs b/packages/cli/src/findings.mjs index cce944632..9b270c449 100644 --- a/packages/cli/src/findings.mjs +++ b/packages/cli/src/findings.mjs @@ -5,6 +5,7 @@ */ export const FINDING_IDS = Object.freeze([ "android-store-flavor-conflict", + "android-store-unknown", "android-store-flavor-mismatch", "android-store-not-play", "android-horizon-app-id-missing", diff --git a/packages/cli/test/doctor.test.mjs b/packages/cli/test/doctor.test.mjs index 0d2fba649..70ace06a3 100644 --- a/packages/cli/test/doctor.test.mjs +++ b/packages/cli/test/doctor.test.mjs @@ -14,6 +14,7 @@ import test from "node:test"; import { FINDING_IDS, doctor, formatText } from "../src/doctor.mjs"; import { finding } from "../src/findings.mjs"; +import { STORE_ALIASES } from "../src/checks.mjs"; import { readFileSync } from "node:fs"; function project(files) { @@ -161,6 +162,390 @@ test("the store the flags selected is the line cited as evidence", () => { ); }); +test("an openiapStore pin selects the store and is the evidence line", () => { + withProject( + { + ...EXPO, + "android/gradle.properties": + "org.gradle.jvmargs=-Xmx2048m\nopeniapStore=horizon\n", + "android/app/src/main/AndroidManifest.xml": + '', + }, + (root) => { + const result = doctor(root); + assert.deepEqual( + result.findings.map((one) => one.id), + ["android-store-not-play"], + ); + assert.equal(result.findings[0].actual, "horizon"); + assert.equal(result.findings[0].line, 2); + }, + ); +}); + +test("store aliases resolve the way the Gradle resolver reads them", () => { + for (const [alias, store] of [ + ["quest", "horizon"], + ["fire-os", "amazon"], + ["GooglePlay", "play"], + ]) { + withProject( + { + ...RN, + "android/gradle.properties": `openiapStore=${alias}\n`, + "android/app/src/main/AndroidManifest.xml": + '', + }, + (root) => { + const notPlay = doctor(root).findings.find( + (one) => one.id === "android-store-not-play", + ); + assert.equal(notPlay?.actual, store === "play" ? undefined : store); + }, + ); + } +}); + +test("store aliases stay in sync with the Gradle resolver table", () => { + const gradle = readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../google/gradle/openiap-store.gradle", + ), + "utf8", + ); + const block = gradle.match(/ext\.openIapStoreAliases\s*=\s*\[(.*?)\]/s)?.[1]; + assert.ok(block, "alias map moved; update this sync test"); + const uncommented = block.replace(/\/\/.*$/gm, ""); + const pairs = [ + ...uncommented.matchAll(/['"]?([\w-]+)['"]?\s*:\s*['"](\w+)['"]/g), + ]; + assert.ok(pairs.length > 0, "alias map parse found no entries"); + assert.deepEqual( + Object.fromEntries(pairs.map((m) => [m[1], m[2]])), + STORE_ALIASES, + ); +}); + +test("openiapStore=auto pins nothing", () => { + withProject( + { ...EXPO, "android/gradle.properties": "openiapStore=auto\n" }, + (root) => assert.deepEqual(ids(root), []), + ); +}); + +test("a pin that disagrees with a legacy flag is a conflict", () => { + withProject( + { + ...EXPO, + "android/gradle.properties": "openiapStore=play\nfireOsEnabled=true\n", + }, + (root) => { + const conflict = doctor(root).findings.find( + (one) => one.id === "android-store-flavor-conflict", + ); + assert.equal(conflict.level, "error"); + assert.equal(conflict.line, 1); + }, + ); +}); + +test("a value that is not a store is an error", () => { + withProject( + { ...EXPO, "android/gradle.properties": "openiapStore=bogus\n" }, + (root) => { + const result = doctor(root); + const unknown = result.findings.find( + (one) => one.id === "android-store-unknown", + ); + assert.equal(unknown.level, "error"); + assert.ok(result.errors >= 1); + }, + ); +}); + +test("a prototype key is not a store", () => { + withProject( + { ...EXPO, "android/gradle.properties": "openiapStore=constructor\n" }, + (root) => { + const unknown = doctor(root).findings.find( + (one) => one.id === "android-store-unknown", + ); + assert.equal(unknown.level, "error"); + }, + ); +}); + +test("the Flutter opt-out is reported like another store, without a mismatch", () => { + withProject( + { + "pubspec.yaml": + "name: app\ndependencies:\n flutter_inapp_purchase: ^10.0.0\n", + "android/gradle.properties": "openiapStore=none\n", + "android/app/build.gradle": + 'missingDimensionStrategy "platform", "play"\n', + }, + (root) => { + const result = doctor(root); + assert.deepEqual( + result.findings.map((one) => one.id), + ["android-store-not-play"], + ); + assert.equal(result.findings[0].actual, "none"); + }, + ); +}); + +test("the opt-out is an error on a wrapper that cannot build it", () => { + withProject( + { ...RN, "android/gradle.properties": "openiapStore=none\n" }, + (root) => { + const unknown = doctor(root).findings.find( + (one) => one.id === "android-store-unknown", + ); + assert.equal(unknown.level, "error"); + assert.match(unknown.message, /not supported by react-native/u); + }, + ); +}); + +// Probed against the real resolver fixture: the doctor must not object to a +// combination Gradle builds, nor stay quiet about one it refuses. +test("the opt-out key is judged exactly as Gradle judges it", () => { + const conflicts = (properties) => { + let ids; + withProject( + { + "pubspec.yaml": + "name: app\ndependencies:\n flutter_inapp_purchase: ^10.0.0\n", + "android/gradle.properties": properties, + }, + (root) => { + ids = doctor(root).findings.map((one) => one.id); + }, + ); + return ids; + }; + + // Gradle builds these three. + for (const properties of [ + "openiapPlatform=none\n", + "openiapStore=auto\nopeniapPlatform=none\n", + "openiapStore=none\nopeniapPlatform=none\n", + ]) { + assert.ok( + !conflicts(properties).includes("android-store-flavor-conflict"), + `${JSON.stringify(properties)} builds, so it is not a conflict`, + ); + } + + // Gradle refuses these three. + assert.ok( + conflicts("openiapStore=play\nopeniapPlatform=none\n").includes( + "android-store-flavor-conflict", + ), + ); + for (const properties of ["openiapPlatform=auto\n", "openiapPlatform=\n"]) { + assert.ok( + conflicts(properties).includes("android-store-unknown"), + `${JSON.stringify(properties)} fails the build`, + ); + } +}); + +// `auto` is absent to the resolver, so the legacy opt-out still applies and a +// wrapper that cannot build without a store SDK must hear about it. +test("an opt-out masked by auto still fails a non-Flutter wrapper", () => { + for (const pin of ["openiapStore=auto\n", "openiapStore=\n"]) { + withProject( + { + "package.json": JSON.stringify({ + dependencies: { "react-native-iap": "^16.0.0" }, + }), + "android/gradle.properties": `${pin}openiapPlatform=none\n`, + }, + (root) => { + const ids = doctor(root).findings; + assert.ok( + ids.some((one) => one.id === "android-store-unknown"), + `${JSON.stringify(pin)} beside the opt-out is unsupported here`, + ); + // The opt-out lives on the legacy key, so that is the line to edit. + assert.match( + ids.find((one) => one.id === "android-store-unknown").message, + /openiapPlatform=none/u, + ); + }, + ); + } +}); + +test("the legacy opt-out key is read, and only accepts none", () => { + withProject( + { + "pubspec.yaml": + "name: app\ndependencies:\n flutter_inapp_purchase: ^10.0.0\n", + "android/gradle.properties": "openiapPlatform=none\n", + }, + (root) => { + const notPlay = doctor(root).findings.find( + (one) => one.id === "android-store-not-play", + ); + assert.equal(notPlay.actual, "none"); + assert.match(notPlay.message, /openiapPlatform=none/u); + }, + ); + withProject( + { + "pubspec.yaml": + "name: app\ndependencies:\n flutter_inapp_purchase: ^10.0.0\n", + "android/gradle.properties": "openiapPlatform=horizon\n", + }, + (root) => { + const unknown = doctor(root).findings.find( + (one) => one.id === "android-store-unknown", + ); + assert.equal(unknown.level, "error"); + assert.match(unknown.message, /only supports the opt-out value none/u); + }, + ); +}); + +// Each row matches what the resolver fixture does with the same gradle.properties. +test("gradle.properties is judged the way Gradle judges it", () => { + const FLUTTER = { + "pubspec.yaml": + "name: app\ndependencies:\n flutter_inapp_purchase: ^10.0.0\n", + }; + for (const [framework, properties, expected, message] of [ + [ + RN, + "horizonEnabled=true\n", + ["android-store-not-play"], + /pinned to the horizon store \(horizonEnabled=true\)/u, + ], + [ + RN, + "openiapStore=auto\nfireOsEnabled=y\n", + ["android-store-not-play"], + /pinned to the amazon store \(fireOsEnabled=y\)/u, + ], + [ + RN, + "horizonEnabled=true\nfireOsEnabled=true\n", + ["android-store-flavor-conflict"], + /both true/u, + ], + [ + RN, + "openiapPlatform=horizon\n", + ["android-store-unknown"], + /only supports the opt-out value none/u, + ], + [ + RN, + "openiapStore=none\n", + ["android-store-unknown"], + /openiapStore=none is not supported/u, + ], + [ + FLUTTER, + "openiapStore=auto\nopeniapPlatform=none\n", + ["android-store-not-play"], + /links no store SDK \(openiapPlatform=none\)/u, + ], + [ + FLUTTER, + "openiapStore=bogus\nopeniapPlatform=none\n", + ["android-store-unknown"], + /openiapStore=bogus is not a store/u, + ], + [ + FLUTTER, + "openiapStore=none\nhorizonEnabled=true\n", + ["android-store-flavor-conflict"], + /openiapStore=none disagrees with horizonEnabled=true/u, + ], + ]) { + withProject( + { ...framework, "android/gradle.properties": properties }, + (root) => { + const found = doctor(root).findings.filter((one) => + one.id.startsWith("android-store"), + ); + assert.deepEqual( + found.map((one) => one.id), + expected, + JSON.stringify(properties), + ); + assert.match(found[0].message, message, JSON.stringify(properties)); + }, + ); + } +}); + +test("an opt-out names the key that set it in the fix too", () => { + withProject( + { + "pubspec.yaml": + "name: app\ndependencies:\n flutter_inapp_purchase: ^10.0.0\n", + "android/gradle.properties": "openiapStore=auto\nopeniapPlatform=none\n", + }, + (root) => { + const notPlay = doctor(root).findings.find( + (one) => one.id === "android-store-not-play", + ); + assert.match(notPlay.fix, /Remove openiapPlatform=none/u); + assert.equal(notPlay.line, 2); + }, + ); +}); + +test("the combinations Gradle refuses are errors, not a clean bill", () => { + // Each of these fails the Android build outright, so reporting it clean sends + // someone to the build to discover it. + for (const [properties, line] of [ + ["openiapStore=play\nopeniapPlatform=none\n", 2], + ["openiapPlatform=none\nhorizonEnabled=true\n", 1], + ]) { + withProject( + { + "pubspec.yaml": + "name: app\ndependencies:\n flutter_inapp_purchase: ^10.0.0\n", + "android/gradle.properties": properties, + }, + (root) => { + const conflict = doctor(root).findings.find( + (one) => one.id === "android-store-flavor-conflict", + ); + assert.equal(conflict.level, "error"); + assert.equal(conflict.line, line); + }, + ); + } +}); + +test("an unpinned project does not state a leftover literal as its store", () => { + withProject( + { + ...RN, + "android/app/build.gradle": + 'missingDimensionStrategy "platform", "horizon"\n', + "android/app/src/main/AndroidManifest.xml": + '', + }, + (root) => { + const notPlay = doctor(root).findings.find( + (one) => one.id === "android-store-not-play", + ); + assert.match( + notPlay.message, + /with no pin in gradle\.properties, each build resolves its own/u, + ); + }, + ); +}); + test("both stores enabled is a conflict", () => { withProject( { diff --git a/packages/conformance/README.md b/packages/conformance/README.md index 17179824c..1ac46fe5d 100644 --- a/packages/conformance/README.md +++ b/packages/conformance/README.md @@ -39,6 +39,8 @@ Behavior ids are permanent public identifiers. Renaming one is a breaking change ## Running the suite +The package is private to this repository; it is not published to npm. + ```js import { runConformance, formatReport } from "openiap-conformance"; @@ -50,8 +52,7 @@ process.exit(report.conformant ? 0 : 1); See the reference run: ```bash -npx openiap-conformance-report # from an install -bun run --cwd packages/conformance report # from this repo +bun run --cwd packages/conformance report ``` The reference client intentionally excludes server-side lifecycle behaviors, diff --git a/packages/docs/eslint.config.js b/packages/docs/eslint.config.js index d3e537f48..62287cd15 100644 --- a/packages/docs/eslint.config.js +++ b/packages/docs/eslint.config.js @@ -1,20 +1,20 @@ -import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import tseslint from "typescript-eslint"; +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; export default tseslint.config( { ignores: [ - "dist", - "eslint.config.js", - "postcss.config.js", - "tailwind.config.js", - "vite.config.ts", - ".history/**", - "node_modules/**", - "build/**", + 'dist', + 'eslint.config.js', + 'postcss.config.js', + 'tailwind.config.js', + 'vite.config.ts', + '.history/**', + 'node_modules/**', + 'build/**', ], }, { @@ -22,7 +22,7 @@ export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommendedTypeChecked, ], - files: ["**/*.{ts,tsx}"], + files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: { @@ -31,49 +31,28 @@ export default tseslint.config( }, parserOptions: { tsconfigRootDir: import.meta.dirname, - project: [ - "./tsconfig.node.json", - "./tsconfig.json", - ], + project: ['./tsconfig.node.json', './tsconfig.json'], }, }, plugins: { - "react-hooks": reactHooks, - "react-refresh": reactRefresh, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": [ - "warn", + 'react-refresh/only-export-components': [ + 'warn', { allowConstantExport: true }, ], - // All of these overrides ease getting into - // TypeScript, and can be removed for stricter - // linting down the line. - - // Only warn on unused variables, and ignore variables starting with `_` - "@typescript-eslint/no-unused-vars": [ - "warn", - { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, + '@typescript-eslint/no-unused-vars': [ + 'warn', + { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, ], - - // Allow escaping the compiler - "@typescript-eslint/ban-ts-comment": "error", - - // Allow explicit `any`s - "@typescript-eslint/no-explicit-any": "off", - - // START: Allow implicit `any`s - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "off", - // END: Allow implicit `any`s - - // Allow async functions without await - // for consistency (esp. Convex `handler`s) - "@typescript-eslint/require-await": "off", }, }, + { + // The SSR entry is never hot-reloaded. + files: ['src/entry-server.tsx'], + rules: { 'react-refresh/only-export-components': 'off' }, + } ); diff --git a/packages/docs/package.json b/packages/docs/package.json index e697931f5..84080179e 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -10,7 +10,7 @@ "build": "bun run typecheck && bun scripts/check-commerce-composition.mjs && bunx vite build && node scripts/prerender.mjs", "test:discoverability": "node --test scripts/prerender.test.mjs", "typecheck": "tsc --noEmit", - "lint": "tsc --noEmit --pretty false && bunx vite build", + "lint": "eslint src --max-warnings 0", "preview": "bunx vite preview", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json}\"", diff --git a/packages/docs/public/commerce-example/README.md b/packages/docs/public/commerce-example/README.md index 6ed1c2d14..3bd792eaa 100644 --- a/packages/docs/public/commerce-example/README.md +++ b/packages/docs/public/commerce-example/README.md @@ -7,15 +7,15 @@ This example grew through seven executable milestones and their review revisions Each folder holds its AI task, source hashes, actual HTTP results, and verification in `run.json`; `source.tar.gz` runs independently and `changes.patch` shows the added code. -| Step | Ask AI to build | What the run demonstrates | -| ------------------------------------------------------ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| [1. Contract](01-contract/run.json) | Load the published schemas and start a local server | Inputs validate; storage is empty; discovery is unfinished and returns an error not permitted for the core operation. | -| [2. Verification](02-verify/run.json) | Recognize fixture evidence and persist the purchase | A purchase exists, but nobody has access yet. Bad evidence and an upstream outage produce different results. | -| [3. Ownership](03-bind/run.json) | Bind through the backend and read access | Alice gains Premium. Verification credentials cannot bind, and Bob cannot take Alice's purchase. | -| [4. Cancellation](04-cancel/run.json) | Stop renewal and queue the event atomically | Alice keeps paid access. Discovery can now advertise an event the implementation actually emits. | -| [5. Delivery](05-deliver/run.json) | Sign, retry, and deduplicate | A failed delivery retries after reopening storage. A repeated delivery has one inbox effect. | -| [6. Reviewed recovery](06-recover-reviewed-8/run.json) | Enforce expiry, check persistence, and map client evidence | The reviewed final version adds atomic binding grants, rejects conflicting expiry, closes access at the deadline, and preserves storage on reopening. | -| [7. Account deletion](07-account-erasure-interoperable-6/run.json) | Erase provider identity and delivered event copies | Repeated erasure, late events, in-flight fulfillment, and reopened storage cannot restore the account. | +| Step | Ask AI to build | What the run demonstrates | +| -------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| [1. Contract](01-contract/run.json) | Load the published schemas and start a local server | Inputs validate; storage is empty; discovery is unfinished and returns an error not permitted for the core operation. | +| [2. Verification](02-verify/run.json) | Recognize fixture evidence and persist the purchase | A purchase exists, but nobody has access yet. Bad evidence and an upstream outage produce different results. | +| [3. Ownership](03-bind/run.json) | Bind through the backend and read access | Alice gains Premium. Verification credentials cannot bind, and Bob cannot take Alice's purchase. | +| [4. Cancellation](04-cancel/run.json) | Stop renewal and queue the event atomically | Alice keeps paid access. Discovery can now advertise an event the implementation actually emits. | +| [5. Delivery](05-deliver/run.json) | Sign, retry, and deduplicate | A failed delivery retries after reopening storage. A repeated delivery has one inbox effect. | +| [6. Reviewed recovery](06-recover/run.json) | Enforce expiry, check persistence, and map client evidence | The reviewed final version adds atomic binding grants, rejects conflicting expiry, closes access at the deadline, and preserves storage on reopening. | +| [7. Account deletion](07-account-erasure/run.json) | Erase provider identity and delivered event copies | Repeated erasure, late events, in-flight fulfillment, and reopened storage cannot restore the account. | ## What review changed @@ -43,10 +43,8 @@ transcripts or recordings of an AI editor typing. Code was adapted incrementally from an earlier internal prototype replaced by this example. No live store purchase or production-provider conformance is demonstrated. -The original [step 6](06-recover/run.json) is retained before the reviewed final -revision. Apply patches in predecessor order: follow each record's `previous` -link back to the first checkpoint, then apply that chain from oldest to newest. -Include intermediate review revisions; folder names do not determine the order. +Apply patches in predecessor order: follow each record's `previous` link back +to the first checkpoint, then apply that chain from oldest to newest. [verification.json](verification.json) records a fresh extraction, source hash comparison, patch application, and npm test for every archived revision. diff --git a/packages/docs/public/commerce-example/REVIEW.md b/packages/docs/public/commerce-example/REVIEW.md index d6a8a15be..376c3cd7e 100644 --- a/packages/docs/public/commerce-example/REVIEW.md +++ b/packages/docs/public/commerce-example/REVIEW.md @@ -1,8 +1,9 @@ # Implementation review log > This log describes the original 2026-09 recording. Its checkpoints were -> superseded when the walkthrough was rebuilt on Commerce Protocol 0.3.0, so -> the observations below name records that are no longer published. The +> superseded when the walkthrough was rebuilt on package 0.3.0, so the +> observations below name records that are no longer published on this site. +> The linked failure logs remain in the example repository's history. The > behaviour each one corrected is still covered by the current chain's tests. Each checkpoint was run before the next feature was added. The source archives @@ -95,14 +96,14 @@ sandbox test. Business roles do not introduce new protocol profiles. The first mapper put store members inside an extra `evidence` object. The installed schema rejected it during `npm test`; the captured failure is -[bridge-first-attempt.txt](06-recover-reviewed-3/bridge-first-attempt.txt). Moving +[bridge-first-attempt.txt](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/e52900c2f040a0c9aa895e4a605b4f3e84940652/docs/build/06-recover-reviewed-3/bridge-first-attempt.txt). Moving `apple`/`google` to the verification input's top level fixed the same check. The final archive and its npm test log record the corrected source. ## Third CLI review The ready receiver's loopback Host check refused a reverse proxy's public Host -header. The saved [proxy failure](06-recover-reviewed-4/proxy-first-attempt.txt) +header. The saved [proxy failure](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/e52900c2f040a0c9aa895e4a605b4f3e84940652/docs/build/06-recover-reviewed-4/proxy-first-attempt.txt) reproduces that case. Signature authentication now protects the receiver independently of Host, and the HTTP demo sends the public Host on health, valid, duplicate, and tampered requests. The report records the observed duplicate @@ -156,8 +157,8 @@ The saved local probe ran the same two rejection cases now in `verify.mjs` against the reviewed-7 receiver, then repeated them against reviewed-8. `06-recover-reviewed-8` authenticates the original body bytes before decoding. -The [original probe](06-recover-reviewed-8/byte-auth-before.txt) and -[repeated probe](06-recover-reviewed-8/byte-auth-after.txt) show both requests +The [original probe](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/e52900c2f040a0c9aa895e4a605b4f3e84940652/docs/build/06-recover-reviewed-8/byte-auth-before.txt) and +[repeated probe](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/e52900c2f040a0c9aa895e4a605b4f3e84940652/docs/build/06-recover-reviewed-8/byte-auth-after.txt) show both requests changing from 200 to 401, with no inbox insertion after the fix. Regression checks also reject correctly signed malformed UTF-8 and accept correctly signed Unicode and BOM bodies. Earlier source archives remain unchanged. diff --git a/packages/docs/public/commerce-example/build-brief.md b/packages/docs/public/commerce-example/build-brief.md index f9c0629bd..2f53311b0 100644 --- a/packages/docs/public/commerce-example/build-brief.md +++ b/packages/docs/public/commerce-example/build-brief.md @@ -5,16 +5,16 @@ purchase flow and prove each milestone before adding the next. ## Read these first -Use my project's package manager to install `openiap-commerce-protocol`. +Use my project's package manager to install `@hyodotdev/openiap-commerce-protocol`. For example, with npm: ```sh -npm install openiap-commerce-protocol +npm install @hyodotdev/openiap-commerce-protocol ``` The equivalent commands are `pnpm add`, `yarn add`, or `bun add` followed by the same package name. Read these files from the installed package directory -(normally `node_modules/openiap-commerce-protocol/`): +(normally `node_modules/@hyodotdev/openiap-commerce-protocol/`): - `SPEC.md`: normative behavior, authorization, lifecycle, and delivery rules. - `generated/openapi/commerce-protocol.openapi.json`: REST request/response API. @@ -22,10 +22,9 @@ same package name. Read these files from the installed package directory - `generated/schemas/commerce-protocol.bundle.schema.json`: offline validation. - `conformance/` and `vectors/`: portable checks and signature fixtures. -For architecture, use the [implementation guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md) -and [whitepaper](https://openiap.dev/commerce-protocol-rationale.pdf). Package 0.1.0 -does not include `DESIGN.md`; read that optional file only when it exists in -your installed version. +For architecture, read the package's `DESIGN.md` (also published as the +[whitepaper](https://openiap.dev/commerce-protocol-rationale.pdf)) and the +example's [implementation guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/docs/build/README.md). The package supplies the contract and test artifacts, not a running backend. Implement the backend in my project. Do not require an OpenIAP or IAPKit checkout, @@ -52,7 +51,7 @@ Build these milestones in order: 1. **Contract:** serve capabilities and validate requests and responses against the generated artifacts. Start with an empty persistent database. Advertise only demonstrated support; do not claim partially implemented profiles. - Package 0.1.0 (protocol 1.0) requires a nonempty event list. Until an event emitter exists, + The protocol 1.0 descriptor requires a nonempty `eventTypes` list. Until an event emitter exists, treat this as unfinished scaffolding, not a provider ready for integration. Core discovery cannot use `UNSUPPORTED_PROFILE` as a valid fallback. 2. **Verification:** accept known fixture evidence, reject invalid evidence, and diff --git a/packages/docs/public/commerce-example/integration-brief.md b/packages/docs/public/commerce-example/integration-brief.md index 122ee9fc3..3b167ff33 100644 --- a/packages/docs/public/commerce-example/integration-brief.md +++ b/packages/docs/public/commerce-example/integration-brief.md @@ -13,15 +13,15 @@ several roles. These are product roles, not additional protocol profiles. ## Start with working code -[Download the complete example](https://github.com/hyodotdev/openiap-commerce-protocol-example/archive/refs/heads/main.zip) +[Download the complete example](https://openiap.dev/commerce-example/source.tar.gz) and extract it into an empty directory. It contains `client-bridge.mjs`, `consumer.mjs`, `webhooks.mjs`, the backend, and their executable checks. The [example repository](https://github.com/hyodotdev/openiap-commerce-protocol-example) -contains the same project and its build history. +holds its build history; its current branch can differ from this recorded source. Use your favorite package manager: `npm install`, `pnpm install`, `yarn install`, -or `bun install`. This example's runtime is Bun. The contract is -`openiap-commerce-protocol` package 0.1.0, protocol 1.0; it does not require Bun. +or `bun install`. This example's runtime is Bun. The contract is the +`@hyodotdev/openiap-commerce-protocol` package, protocol 1.0; it does not require Bun. - `npm run demo:bridge`: maps Apple, Google, Amazon, and Horizon OpenIAP purchase fields into the installed verification schema; rejects missing or unsupported evidence. @@ -44,11 +44,11 @@ linked handlers and checks for the responsibility you own. The installed specification defines the required behavior; neither implementation changes it. Inspect this repository's purchase flow and choose the role from the table. -Install `openiap-commerce-protocol` with this repository's package manager. +Install `@hyodotdev/openiap-commerce-protocol` with this repository's package manager. Read its `SPEC.md`, generated bindings and schemas, and signature/lifecycle -vectors for the role being implemented. Package 0.1.0 does not ship `DESIGN.md`; -the [role guide](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/INTEGRATE.md) -and [whitepaper](https://openiap.dev/commerce-protocol-rationale.pdf) give context. +vectors for the role being implemented. Its `DESIGN.md` (also published as the +[whitepaper](https://openiap.dev/commerce-protocol-rationale.pdf)) and the +[role guide](https://openiap.dev/commerce-protocol/ecosystem) give context. Implement the selected role using the product's existing framework and design system. Deliver usable code and a short connection example, not a list of work @@ -71,7 +71,7 @@ for the app team. Follow these boundaries: 3. **Commerce:** provide core discovery and implement every operation/obligation of each advertised profile and binding. Account lifecycle includes erasure. Keep one authoritative ownership and entitlement service for each app/project, - even when it delegates verification. Use [backend build brief](https://github.com/hyodotdev/openiap-commerce-protocol-example/blob/main/BUILD.md) for the backend + even when it delegates verification. Use [backend build brief](https://openiap.dev/commerce-example/build-brief.md) for the backend implementation sequence. The fixture backend advertises no complete profiles. 4. **Data:** reuse or port `webhooks.mjs` and `consumer.mjs`. Authenticate exact body bytes before parsing, validate, durably deduplicate in the configured @@ -94,12 +94,12 @@ implement that callback by copying a user ID from the request body. Follow the six-step purchase flow with your chosen store. Verification and binding use these evidence shapes: -| Store | Purchase evidence | IAPKit access path | -| --- | --- | --- | -| Apple | `apple.jws` from the store purchase | Bind the verified subscription; read its current state and listen for lifecycle events | -| Google | `google.purchaseToken` | Bind the verified subscription; read its current state and listen for lifecycle events | -| Amazon | `amazon.userId`, `amazon.receiptId`, optional `amazon.sandbox` | Bind the verified receipt; each entitlement read rechecks RVS | -| Meta Horizon | `horizon.userId`, `horizon.sku` | Bind the verified store-user/SKU pair; each entitlement read rechecks Meta | +| Store | Purchase evidence | IAPKit access path | +| ------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Apple | `apple.jws` from the store purchase | Bind the verified subscription; read its current state and listen for lifecycle events | +| Google | `google.purchaseToken` | Bind the verified subscription; read its current state and listen for lifecycle events | +| Amazon | `amazon.userId`, `amazon.receiptId`, optional `amazon.sandbox` | Bind the verified receipt; each entitlement read rechecks RVS | +| Meta Horizon | `horizon.userId`, `horizon.sku` | Bind the verified store-user/SKU pair; each entitlement read rechecks Meta | For Amazon and Horizon, use `entitlements.productIds` for access. IAPKit does not invent a subscription record, expiry date, or lifecycle event for these diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 64caaffeb..f44f0fda4 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Vendor-neutral in-app purchase standard. OpenIAP governs exactly two protocols. The Client Protocol (@hyodotdev/openiap-client-protocol) is the purchase API an app calls across Apple, Google, Meta Horizon and Amazon; openiap-apple, openiap-google and the six framework libraries implement it. The Commerce Protocol (@hyodotdev/openiap-commerce-protocol) is the server-side contract for verification, entitlements and store events; any backend may implement it, and IAPKit is one such implementation. Each protocol is versioned by its own npm package. A version labelled "OpenIAP Spec" in older material belongs to a retired lineage that numbered the client contract 2.x and 3.x in step with the native libraries; the Client Protocol is now versioned on its own from 0.1.0, so its version and a native library version are never comparable numbers. > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-09-18T18:47:47.958Z +> Generated: 2026-09-24T03:48:33.254Z ## Reading instructions for coding assistants @@ -207,14 +207,12 @@ pod 'openiap', '~> 3.4.0' ### Kotlin (Android) ```kotlin -// Gradle (build.gradle.kts) +// app/build.gradle.kts implementation("io.github.hyochan.openiap:openiap-google:3.5.2") -// For Meta Horizon OS -implementation("io.github.hyochan.openiap:openiap-google-horizon:3.5.2") - -// For Fire OS (Amazon Appstore) -implementation("io.github.hyochan.openiap:openiap-google-amazon:3.5.2") +// settings.gradle.kts, for Meta Horizon OS and Fire OS: keep the Play coordinate; +// the plugin links openiap-google-horizon / -amazon by the store rule. +plugins { id("io.github.hyochan.openiap") version "3.5.2" } ``` ### Flutter @@ -255,8 +253,9 @@ Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. `packages/google`. - Public surface: generated OpenIAP types plus `useIAP`, listener helpers, and platform-suffixed iOS/Android APIs. -- Android builds select Play, Horizon, or Fire OS with Gradle properties - (`horizonEnabled`, `fireOsEnabled`). Vega OS uses a separate React Native +- Android builds resolve the store at build time: an `openiapStore` pin, the + store flavor in the requested task, or on debug builds the connected device. + `horizonEnabled` and `fireOsEnabled` are deprecated. Vega OS uses a separate React Native for Vega target that resolves the `kepler` JavaScript adapter before creating the Nitro HybridObject. - Onside is not supported in `react-native-iap`; use `expo-iap` for Onside. @@ -267,10 +266,13 @@ Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. - Implementation: Expo Modules wrapper over the same native OpenIAP packages. - Public surface: same hook, listener, query, mutation, and platform API shape as `react-native-iap`, adapted for Expo managed/bare workflows. -- Config plugins can select Horizon, Fire OS, Vega OS, and Onside: - `modules.horizon` + `android.horizon.appId`, - `modules.amazon.fireOS`, `modules.amazon.vegaOS`, optional - `android.amazon.vegaOS` metadata, and `modules.onside`. +- The Android store is resolved at build time: the connected device on a + local debug build, `ORG_GRADLE_PROJECT_openiapStore` in the EAS profile + env for EAS and release builds, which have no device to follow. The config plugin carries store values only: + `android.horizon.appId`, `android.amazon.appstoreKey`, and opt-ins + `modules.amazon.vegaOS` (optional `android.amazon.vegaOS` metadata) + and `modules.onside`. `modules.horizon` / `modules.amazon.fireOS` + are deprecated pins. - Example app: `libraries/expo-iap/example`. ### flutter_inapp_purchase @@ -306,10 +308,12 @@ Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. the official `OpenIap.Maui.Bindings.iOS.resources.zip` sidecar so no app-level `NativeReference` is required. - Android bridge: Xamarin.Android binding over the MAUI-owned - `openiap-release.aar`, which wraps the unbound - `openiap-play-release.aar` runtime dependency. Google Billing, Play - Services, Gson, AndroidX, and Kotlin Android libraries stay as NuGet - `PackageReference` dependencies so consuming apps can deduplicate them. + `openiap-release.aar`. The package carries every store's + `openiap-google` AAR, and its `buildTransitive` targets link one per + app build (`OpenIapStore`, else the device on a Debug build, else Play) + with that store's SDK from Maven. Play Services, Gson, AndroidX, Kotlin, + and kotlinx-serialization stay NuGet `PackageReference` dependencies, so + every MAUI build carries them whatever the store (MAUI only). - Public surface: `QueryResolver`, `MutationResolver`, and `IOpenIap` implemented by `OpenIapIOS`, `OpenIapAndroid`, and `OpenIapMacCatalyst`; app-facing IAPKit helpers mirror the TypeScript SDKs via @@ -327,25 +331,31 @@ Canonical setup docs live under `/docs/setup/store`: - Google Play: default Android artifact, `openiap-google`. - Meta Horizon: Android `horizon` flavor, `openiap-google-horizon`. - Expo uses `modules.horizon=true` and `android.horizon.appId`. - React Native and Flutter use `horizonEnabled=true` plus app-owned manifest - metadata. KMP exposes `horizonRelease`. MAUI uses - `OpenIapAndroidStore=horizon`. Godot has no dedicated Horizon selector. + Expo keeps `android.horizon.appId` in the config plugin. + Expo, React Native and Flutter resolve it from `openiapStore=horizon`, an + `assembleHorizon*` task, or a connected Quest on a debug build, plus + app-owned manifest metadata. Native Android and KMP apps get the same rule + from the `io.github.hyochan.openiap` Gradle plugin. MAUI follows a + connected Quest on a Debug build, or pins `OpenIapStore=horizon`. Godot + follows a connected Quest on a debug export, + or pins `openiap/android_store=horizon`. Required values: Horizon app id from Meta Horizon Developer Hub - (Expo: `android.horizon.appId`; bare RN/Flutter examples commonly pass a + (Expo: `android.horizon.appId`; Godot: the `openiap/horizon_app_id` export + option; bare RN/Flutter examples commonly pass a Gradle property named `horizonAppId` into manifest meta-data), product SKUs, and verification values such as `horizon.sku`, `horizon.userId`, and `horizon.accessToken` when validating Horizon purchases. - Fire OS: Android `amazon` flavor, - `openiap-google-amazon`; use `modules.amazon.fireOS=true` - in the Expo config plugin, or - `missingDimensionStrategy("platform", "amazon")` in bare Android / - React Native / Flutter app Gradle config. + `openiap-google-amazon`, picked by the same rule: a connected Fire device + on a debug build, or `openiapStore=amazon` (Expo: in the EAS profile env). + Native Android and KMP apps get the rule from the `io.github.hyochan.openiap` + Gradle plugin. Runtime adapters are wired for native Android, `react-native-iap`, - `expo-iap`, `flutter_inapp_purchase`, KMP `amazonRelease`, and MAUI - `OpenIapAndroidStore=amazon`. Godot has shared Amazon types and - verification payloads but no dedicated Fire OS flavor switch. + `expo-iap`, `flutter_inapp_purchase`, KMP, and MAUI (a connected Fire + device on a Debug build, or `OpenIapStore=amazon`). Godot follows a + connected Fire device on a debug export, or pins + `openiap/android_store=amazon`. Required values: Android `applicationId` matching the Amazon Developer Console app, Amazon Appstore product ids / App Tester catalog entries, and the Amazon public key for Fire OS Android builds. Receipt verification and @@ -358,8 +368,8 @@ Canonical setup docs live under `/docs/setup/store`: Bare React Native Vega targets provide their own `manifest.toml`, Kepler package metadata, and runtime dependencies. - `modules.amazon.fireOS` and `modules.amazon.vegaOS` can both be enabled - when an app produces separate Fire OS and Vega OS artifacts. + A Fire OS build of the same app is a separate artifact that the Android + build picks like any other store. Required values: Vega `manifest.toml` package id, title, interactive component id, Kepler runtime/module declarations, Amazon product ids, and Vega runtime dependencies. In Expo, optional `android.amazon.vegaOS` overrides @@ -389,10 +399,9 @@ Fire OS maps OpenIAP calls to the Amazon Appstore SDK: Vega OS is not Fire OS and is not selected with `fireOsEnabled=true`; that flag is only for Android Fire OS builds. Use `modules.amazon.vegaOS=true` -for the Vega runtime target in Expo, and `modules.amazon.fireOS=true` for -separate Fire OS Android artifacts in the Expo config plugin. Bare React Native -uses direct Gradle flavor selection for Fire OS and a separate Kepler target for -Vega. Install +for the Vega runtime target in Expo; the Fire OS Android artifact is picked by +the store rule like any other store. Bare React Native uses the same rule for +Fire OS and a separate Kepler target for Vega. Install `@amazon-devices/keplerscript-appstore-iap-lib` and let `react-native-iap` / `expo-iap` select the `kepler` adapter at runtime, similar to how Onside is selected at the runtime integration layer. @@ -1780,8 +1789,8 @@ This document provides external API reference for Apple's StoreKit 2 framework. | `Product.SubscriptionInfo.RenewalInfo.eligibleWinBackOfferIDs` | iOS 18.0 | Query win-back offer eligibility before purchase | | Consumable transaction history | iOS 18.0 | Opt-in via `SKIncludeConsumableInAppPurchaseHistory` Info.plist key | | StoreKit `Message.billingIssue` | iOS / Mac Catalyst 16.4, visionOS 1.0 | Listener for subscription billing issues (`Message` is unavailable on macOS, tvOS, and watchOS) | -| UI context for purchases | iOS 18.2 | Required for proper payment sheet display | -| External purchase notice | iOS 17.4 | `ExternalPurchase.presentNoticeSheet()` | +| UI context for purchases | iOS 17.0 | `purchase(confirmIn:)` takes a `UIScene`; iOS 18.2 adds `UIViewController`, macOS 15.2 `NSWindow` | +| External purchase notice token | iOS 17.4 | `ExternalPurchase.canPresent`; `presentNoticeSheet()` returns a token | | `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) | | `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) | | `Transaction.offerPeriod` | iOS 18.4 | Offer period information on Transaction | @@ -2130,9 +2139,11 @@ if renewalInfo.renewalOfferType == .winBack { } ``` -## UI Context for Purchases (iOS 18.2+) +## UI Context for Purchases -Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets: +`purchase(confirmIn:)` takes a `UIScene` from iOS 17.0; iOS 18.2 adds a +`UIViewController` overload and macOS 15.2 an `NSWindow` one. Apple recommends a UI-context purchase API over +`purchase(options:)` everywhere except watchOS: ```swift // iOS/iPadOS/tvOS/visionOS: UIViewController @@ -2311,10 +2322,11 @@ By default, `Transaction.all` omits finished consumables. Opt in by adding this With the key set, finished consumable transactions are included in `Transaction.all`, `Transaction.latest(for:)`, and `Product.latestTransaction`. -## External Purchase Support (iOS 17.4+) +## External Purchase Support -`ExternalPurchase.presentNoticeSheet()` / `ExternalPurchaseLink.open(url:)` -ship on iOS 17.4+. The follow-on custom-link APIs +`ExternalPurchase.presentNoticeSheet()` ships on iOS 15.4. `canPresent` and the +sheet's `continuedWithExternalPurchaseToken(token:)` result arrive in iOS 17.4. +`ExternalPurchaseLink.open(url:)` is iOS 17.5+. The custom-link APIs (`ExternalPurchaseCustomLink.isEligible`, `showNotice(type:)`, `token(for:)`) are iOS 18.1+. @@ -2456,7 +2468,9 @@ executable projection validators consume. | `generated/vectors/lifecycle.json` | Generated entitlement, first-binding, and event-emission vectors implementations reproduce | | `generated/bindings/http-binding.json` | Generated HTTP manifest: method, path, auth role, statuses, and schema pointer per operation | | `generated/bindings/operations.graphql` | Generated executable GraphQL projection the GraphQL binding serves | +| `generated/bindings/operations-sdl.json` | Generated JSON wrapper of `operations.graphql` for bundlers; the SDL is byte-identical | | `generated/bindings/graphql-operations.json` | Generated canonical full-selection GraphQL documents | +| `generated/bindings/introspection-signature.json` | Generated structural signature the runner checks served introspection against (§11.3) | | `generated/openapi/commerce-protocol.openapi.json` | Generated OpenAPI 3.1 document for the REST binding | | `generated/vectors/operations.json` | Generated operation conformance vectors | | `conformance/` | The portable conformance runner and its independent mock provider | @@ -2536,11 +2550,18 @@ consumer doing revenue reconciliation SHOULD accept only `store`. **Identifiers** are opaque strings. A consumer MUST NOT parse structure out of one. -**Enumerations** are closed unless this document says otherwise. Four value -spaces are deliberately open — `environment` here, `store` below, -`cancellationReason` on the subscription snapshot, and `eventType`, which §12 -grows in a MINOR version — and a consumer MUST tolerate a value it does not -recognise in any of them, and MUST NOT act on one it does not know. +**Enumerations** are closed unless this document says otherwise. These value +spaces are open, and a MINOR version can add a value to any of them (§12): + +- `store` (below) and `environment` +- `cancellationReason` (§2.2) +- `eventType` (§9.1) +- the verification `state` (§4.1) and the erasure job `status` (§4.5) +- protocol error codes (§8) +- profile and binding names (§3, §10.1) + +Whoever reads one MUST tolerate a value it does not recognise and MUST NOT act +on one it does not know. §8 says how a caller treats an unrecognised error code. **Store, not platform.** This specification keys on `store`, never on device platform. One device platform can host several stores — an Android build can @@ -2590,8 +2611,9 @@ unlike `price`, it carries no provenance. Entitlement is carried as `subscription.active`, and where a `subscription` member is present that is the field to read — never a re-derivation from -`state`. A store that keeps no canonical subscription record sends no snapshot; -there the `entitlement.*` event type itself carries the decision (§9.5). +`state`. An `entitlement.*` event may omit the snapshot where the store keeps no +canonical subscription record; its event type then carries the decision (§9.5). +No implementation emits that shape yet (§14). **Entitlement is not derivable from `state` alone**, and this is where naive implementations go wrong: @@ -2708,7 +2730,11 @@ a consumer MUST ignore a profile name it does not recognise. A provider implements a profile **completely or not at all**. It MUST declare in its capability descriptor (§10) every profile it serves and MUST NOT declare one it serves partially or does not pass conformance for (§11). -Profiles version independently as MAJOR.MINOR; a caller pins on the major. +A provider whose descriptor lists profiles MUST fail an operation from any +profile it does not list with `UNSUPPORTED_PROFILE`. Authorization comes first +(§5): a credential the provider did not issue for the operation's role still +gets `UNAUTHORIZED` or `FORBIDDEN`. Profiles version independently as +MAJOR.MINOR; a caller pins on the major. --- @@ -2762,10 +2788,12 @@ input to it, select or mutate account state. ### 4.2 subscriptionStatus -A developer backend reads one user's subscription standing: an `active` gate -for the user as a whole, plus the most relevant record — the current -entitling subscription when one exists, otherwise the provider's most recent -record as context, and no record member at all when the provider has none. +A developer backend reads one user's subscription standing. `active` says +whether the user holds a currently entitling subscription. The result also +carries the most relevant record — the current entitling subscription when one +exists, otherwise the provider's most recent record as context, and no record +member at all when the provider has none. Access that does not come from a +subscription appears only in `entitlements` (§4.3). The snapshot is **tokenless by construction**: no purchase token, store transaction identity, signed receipt, or provider-internal record identifier @@ -2774,10 +2802,17 @@ because with it a shipped app could walk arbitrary user identities. ### 4.3 entitlements -The access decision for one user: every product whose gate is open at the -provider's read time, with the entitling records. Unknown, expired, and -ambiguous records contribute nothing. The same tokenless and server-role -rules as §4.2 apply. +The access decision for one user: `productIds` lists every product whose gate +is open at the provider's read time, and `subscriptions` the entitling +subscription records. A product can be granted without a subscription record, +such as a durable purchase, so `productIds` may name products no record +carries; every record's product is in `productIds`. Unknown, expired, and +ambiguous records contribute nothing. The same tokenless and server-role rules +as §4.2 apply. + +A provider that rechecks access with a store and cannot get its answer MUST +fail the read with `VERIFICATION_FAILED` (§8) rather than answer from what it +has. ### 4.4 bindPurchase @@ -2786,12 +2821,13 @@ the identity space of §2.4. Server role only: token possession is deliberately not proof of ownership, so binding is a decision the caller's authenticated backend makes, never a shipped app. -Binding is idempotent and never moves an existing binding. `bound: false` -covers every non-binding outcome — unknown evidence, evidence bound to a -different user, a store the provider cannot bind — without distinguishing -them, so the operation cannot probe whether someone else's purchase exists. -How a provider recovers a purchase bound to the wrong user is management -plane, outside this contract. +Binding is idempotent and never moves an existing binding. For a store the +provider integrates, `bound: false` covers every non-binding outcome — unknown +evidence, evidence bound to a different user — without distinguishing them, so +the operation cannot probe whether someone else's purchase exists. A store the +provider does not integrate is `UNSUPPORTED_STORE`, as in §4.1. How a provider +recovers a purchase bound to the wrong user is management plane, outside this +contract. ### 4.5 eraseUser @@ -2817,8 +2853,8 @@ runner reads it the same way a caller does. ## 5. Authentication and trust The protocol standardizes **roles and rules**, not credential formats. How a -provider issues, names, or rotates credentials is its own business; no -prefix, length, or issuer is part of this contract. +provider issues, names, or rotates credentials is its own business; a +credential's prefix, length, and issuer are outside this contract. | Role | Holder | May call | | ---------------- | ---------------------------------- | ---------------------------------------------------- | @@ -2828,27 +2864,27 @@ prefix, length, or issuer is part of this contract. Both bindings MUST enforce: -- Credentials travel in the `Authorization` header. A provider MUST NOT - accept a secret in a URL path or query string, where proxies and logs - retain it. +- A credential travels as `Authorization: Bearer ` (RFC 6750). + A provider MUST NOT accept a secret in a URL path or query string, where + proxies and logs retain it. - Auth failures fail close: no credential is `UNAUTHORIZED`, a credential of the wrong role is `FORBIDDEN`, and neither response reveals whether the target of the call exists. - For an operation that requires the **server** role, authorization precedes - input validation: a caller without a valid server credential MUST receive - `UNAUTHORIZED` or `FORBIDDEN`, never a verdict about its input — an + input validation. A caller without a valid server credential MUST receive + `UNAUTHORIZED` or `FORBIDDEN`, never a verdict about its input: an input-validation answer would let an unauthenticated caller map the privileged surface (which stores bind, which members exist, which bounds - apply). Transport-shape failures — an unparseable or oversized body, or a - GraphQL document that fails parsing or validation — MAY still precede - authorization: they say nothing operation-specific. Variable coercion - against the operation input IS input validation, not transport shape — a - GraphQL engine coerces variables before any resolver runs, so a provider - that authorizes only inside resolvers violates this rule and MUST - authorize the operation before executing the document. Verification-role - operations are exempt - because their input schema is the published client contract an application - already ships with. + apply). + - Verification-role operations are exempt, because their input schema is + the published client contract an application already ships with. + - Transport-shape failures MAY still precede authorization, because they + say nothing operation-specific: an unparseable or oversized body, or a + GraphQL document that fails parsing or validation. + - Variable coercion against the operation input is input validation, not + transport shape. A GraphQL engine coerces variables before any resolver + runs, so a provider MUST authorize the operation before executing the + document; authorizing only inside resolvers violates this rule. - The verification role and the server role are distinct credentials. A provider MUST NOT let a verification credential reach an account read or mutation, which is what blocks arbitrary-`userId` lookups from shipped @@ -2883,6 +2919,8 @@ offline bundle. `Content-Type: application/json`. - Success is exactly the operation's `successStatus`. Every failure returns the status §8 assigns to its code, with a `ProtocolErrorResponse` body. +- A request whose method and path under `/commerce/v1` match no operation + fails with `NOT_FOUND`. - An unrecognised input member is ignored (§4), and a caller MUST ignore unrecognised result members — the same open-object rule the event envelope follows. @@ -2928,15 +2966,17 @@ provider MAY still gate introspection behind a credential. delivered at `200`. This includes a refusal decided before execution, such as an authorization or rate-limit rejection; a pre-execution refusal omits the `data` member. -- A request-level failure — the document or variables themselves could not - be processed: unparseable document, validation failure, variable coercion - — MAY carry no protocol code or MAY carry the generic `INVALID_REQUEST`, - never a more specific code. The two categories are exclusive per envelope: - one `errors` array is either all coded or all codeless — a codeless entry - riding beside coded ones would be invisible to every code check. It omits the `data` member entirely, and only - the codeless form MAY be delivered as HTTP `400` instead of `200`. A - caller treats either form as `INVALID_REQUEST`; only where the request - died differs. +- A request-level failure is one where the document or variables could not + be processed: an unparseable document, a validation failure, or variable + coercion. + - It MAY carry no protocol code or the generic `INVALID_REQUEST`, never a + more specific code. + - Its response omits the `data` member entirely. + - Only its codeless form MAY be delivered as HTTP `400` instead of `200`. + - A caller treats either form as `INVALID_REQUEST`; only where the request + died differs. +- One `errors` array is either all coded or all codeless. A codeless entry + beside coded ones would be invisible to every code check. - GraphQL cannot express omitted-versus-null on a selected member: a member the provider omitted comes back as `null`. Operation types therefore never make `null` meaningful (the compiler rejects a nullable omittable member), @@ -2964,13 +3004,11 @@ traces, or implementation source paths. | `INVALID_REQUEST` | 400 | The input is malformed or fails the operation schema | | `UNAUTHORIZED` | 401 | No usable credential was presented | | `FORBIDDEN` | 403 | The credential's role may not call this operation | -| `NOT_FOUND` | 404 | The addressed resource does not exist | -| `PURCHASE_NOT_FOUND` | 404 | The evidenced purchase is unknown, where an operation distinguishes that | -| `CONFLICT` | 409 | The request contradicts current state | +| `NOT_FOUND` | 404 | The REST method and path match no operation (§6) | | `UNSUPPORTED_STORE` | 422 | The provider does not integrate the named store | | `RATE_LIMITED` | 429 | Too many requests; retry after the signalled delay | | `INTERNAL_ERROR` | 500 | The provider failed internally | -| `UNSUPPORTED_PROFILE` | 501 | The operation belongs to a profile this provider does not serve | +| `UNSUPPORTED_PROFILE` | 501 | The operation belongs to a profile the provider does not declare (§3) | | `VERIFICATION_FAILED` | 502 | The provider could not obtain a verdict — never the store rejecting evidence | The space is open: a MINOR version can add a code, so a caller MUST treat an @@ -3422,9 +3460,10 @@ The consumer revokes access on `entitlement.revoked`. It could equally act on same meaning for every store — including a store that produces no subscription lifecycle at all (§10). -> On such a store the event arrives with **no `subscription` member**, because -> there is no canonical record to snapshot. `eventType` alone then carries the -> access decision, which is why the reference consumer below handles both. +> For such a store an entitlement event would carry **no `subscription` +> member**, because there is no canonical record to snapshot, and `eventType` +> alone would carry the access decision. No implementation emits that shape yet +> (§14), but the schema allows it, so the reference consumer below handles both. #### What the consumer had to know @@ -3531,8 +3570,9 @@ Each capability carries **two** booleans, deliberately separate: They differ in practice. Amazon publishes Real-Time Notifications that a given backend may not have integrated; that is an implementation gap, not a store -limitation, and collapsing the two into one boolean hides which one it is. A -`notes` string is **required** whenever either is false or the two disagree. +limitation, and collapsing the two into one boolean hides which one it is. +`implementation` MUST NOT be true where `provider` is false. A `notes` string +is **required** whenever either is false. `examples/provider-capabilities.json` is the reference implementation's own descriptor. Read its `implementation` axis as one backend's answer, not as the @@ -3631,17 +3671,37 @@ import { runConformance, } from "@hyodotdev/openiap-commerce-protocol/conformance"; +// Bare credential values; the adapters send them as Bearer tokens (§5). +const credentials = { + verification: process.env.COMMERCE_VERIFICATION_TOKEN, + server: process.env.COMMERCE_SERVER_TOKEN, +}; +const adapters = [ + createRestAdapter({ + baseUrl: process.env.COMMERCE_BASE_URL, + fetch, + credentials, + }), +]; +if (process.env.COMMERCE_GRAPHQL_URL) { + adapters.push( + createGraphqlAdapter({ + url: process.env.COMMERCE_GRAPHQL_URL, + fetch, + credentials, + }), + ); +} + const report = await runConformance({ - adapters: [ - createRestAdapter({ baseUrl, fetch, credentials }), - createGraphqlAdapter({ url: graphqlUrl, fetch, credentials }), - ], + adapters, Ajv, - // The same role-to-credential map the adapters use — required, so the - // runner can reject an error message that echoes a credential. + // Required: the runner rejects an error message that echoes a credential. credentials, - eventsAdapter, // required when the descriptor declares the events profile + // Add your eventsAdapter here if the descriptor declares the events profile. }); +console.log(JSON.stringify(report, null, 2)); +process.exitCode = report.ok ? 0 : 1; ``` It is offline and decentralized by construction: it talks only through the @@ -3659,33 +3719,39 @@ signing-only adapter. ### 11.3 What the vectors prove — and what they cannot The operation vectors (`generated/vectors/operations.json`) exercise auth -negatives, invalid and unknown-member inputs, unsupported stores, mismatched -evidence, idempotent repeats, tokenless responses, error-code and -HTTP-status agreement, capability honesty, and REST/GraphQL parity. Their -purchase evidence is fake but well-formed, so a provider without store -credentials still verifies its transport contract; a verdict for that -evidence is accepted as either a schema-valid result or -`VERIFICATION_FAILED`. +negatives, invalid and unknown-member inputs, unsupported stores and profiles, +mismatched evidence, unknown users, idempotent repeats, tokenless responses, +error-code and HTTP-status agreement, capability honesty, and REST/GraphQL +parity. Their purchase evidence is fake but well-formed, so a provider without +store credentials still verifies its transport contract; a verdict for that +evidence is accepted as either a schema-valid result or `VERIFICATION_FAILED`. They therefore certify the **contract**, not the **stores**: passing says nothing about whether real Apple or Google receipts validate correctly. -Beyond the operation vectors, the runner also checks the capability -descriptor's version agreement against the manifest and — on the GraphQL -binding — probes that the endpoint is a real executor (a malformed document, -an undefined field, and a mistyped variable must each be rejected, without -echoing the submitted value; introspection, where enabled, must agree -STRUCTURALLY with the generated signature — kinds, field and argument types -with their nullability, input members, closed enum value sets, and closed -object member sets. A compatible MINOR may add types, nullable arguments, and -members to open objects; it cannot extend a closed object). Event Delivery conformance is likewise separate — §9's -signature, delivery-envelope, response-semantics, and lifecycle vectors -cover it, driven through the provider's events adapter — and a signing-only -provider does not pass it. The events vectors do not reach everything §9 -requires of a production emitter: the §9.3 event-document schema, §9.4.4 -backoff and dead-lettering, §9.4.5 destination safety, and §9.2 store -mapping are certified by an implementation's own tests, not by this -adapter surface. And a provider can pass while serving fixture data; -conformance is a floor, not an audit. + +Beyond the operation vectors, the runner checks: + +- that the capability descriptor's versions agree with the manifest; +- on the GraphQL binding, that the endpoint is a real executor: a malformed + document, an undefined field, and a mistyped variable must each be rejected + without echoing the submitted value; +- that introspection, where enabled, agrees structurally with the generated + signature: kinds, field and argument types with their nullability, input + members, closed enum value sets, and closed object member sets. A + compatible MINOR may add types, nullable arguments, and members to open + objects; it cannot extend a closed object. + +The adapters reach declared operations only, so §6's `NOT_FOUND` for an +unmatched method and path is certified by an implementation's own tests. + +Event Delivery conformance is separate. §9's signature, delivery-envelope, +response-semantics, and lifecycle vectors cover it, driven through the +provider's events adapter, and a signing-only provider does not pass it. The +events vectors do not reach everything §9 requires of a production emitter: +the §9.3 event-document schema, §9.4.4 backoff and dead-lettering, §9.4.5 +destination safety, and §9.2 store mapping are certified by an +implementation's own tests, not by this adapter surface. And a provider can +pass while serving fixture data; conformance is a floor, not an audit. --- @@ -3701,9 +3767,9 @@ facts table use the same value as `commerceProtocolVersion`. **Consumers pin on A MINOR that leaves the event body untouched does not oblige an emitter to change `eventVersion`: that member names the version the body conforms to, not the newest version published. Until the first stable package release, 1.0 stays -open for additive documents, so a new document does not move the protocol -version at all. The npm package version is separate again, and moves only when -the release workflow publishes. +open for additive changes: a new document, or an existing error code declared on +another operation, does not move the protocol version at all. The npm package +version is separate again, and moves only when the release workflow publishes. While the package major is `0`, that latitude extends to renaming a wire member: the protocol major does not move, because moving it would relocate @@ -3737,7 +3803,7 @@ the retired name for as long as the code lives. | --------------------------------------------------------------------------------------------------------- | ----------------- | | New optional member on an open object | MINOR | | New event type | MINOR | -| New value in an open value space (`store`, `environment`, `cancellationReason`, `eventType`) | MINOR | +| New value in an open value space (§2.1 lists them) | MINOR | | New operation, new profile, or new optional operation input member | MINOR | | New protocol error code, or a new evidence member for a new store | MINOR | | New document: a schema root, its example, and a MUST tying it to an existing document | MINOR once stable | @@ -3863,6 +3929,16 @@ storage or tooling. product; what is absent is the one-time purchase's economic-event taxonomy. - **Refund amounts and partial refunds.** `subscription.refunded` reports that a refund occurred, not how much was returned. +- **Entitlement events without a subscription snapshot.** The event schema + allows an `entitlement.*` event with no `subscription` member, the shape a + store with no canonical subscription record would produce (§2.3, §9.5). No + implementation emits one yet. +- **Purchase-not-found and conflict errors.** No 1.0 operation reports an + unknown purchase or a conflicting state as an error: `bindPurchase` answers + `bound: false` for both (§4.4). A later version that needs them adds codes + (§12). PURCHASE_NOT_FOUND and CONFLICT were removed before 1.0 without a + version move: no operation ever declared them, so pinned callers only + delete dead branches. - **Trial and introductory-offer state.** Offers are catalog metadata here, not a property of a live subscription. - **Storefront and country.** diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index d065013ea..2e1acfd81 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Vendor-neutral in-app purchase standard. OpenIAP governs exactly two protocols. The Client Protocol (@hyodotdev/openiap-client-protocol) is the purchase API an app calls across Apple, Google, Meta Horizon and Amazon; openiap-apple, openiap-google and the six framework libraries implement it. The Commerce Protocol (@hyodotdev/openiap-commerce-protocol) is the server-side contract for verification, entitlements and store events; any backend may implement it, and IAPKit is one such implementation. Each protocol is versioned by its own npm package. A version labelled "OpenIAP Spec" in older material belongs to a retired lineage that numbered the client contract 2.x and 3.x in step with the native libraries; the Client Protocol is now versioned on its own from 0.1.0, so its version and a native library version are never comparable numbers. > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-09-18T18:47:47.958Z +> Generated: 2026-09-24T03:48:33.254Z ## Reading instructions for coding assistants @@ -183,10 +183,11 @@ npm install react-native-iap ``` ```kotlin -// Gradle +// settings.gradle.kts: links the Horizon or Amazon build by the store rule +plugins { id("io.github.hyochan.openiap") version "3.5.2" } + +// app/build.gradle.kts implementation("io.github.hyochan.openiap:openiap-google:3.5.2") -implementation("io.github.hyochan.openiap:openiap-google-horizon:3.5.2") -implementation("io.github.hyochan.openiap:openiap-google-amazon:3.5.2") ``` ```bash @@ -221,8 +222,9 @@ Current NuGet package version: 2.5.0 - `maui-iap`: `OpenIap.Maui` package with `OpenIapClient.Instance`, generated `Types.cs`, app-facing IAPKit helpers (`OpenIapClient.KitApi`), flattened OpenIAP-owned iOS - xcframework / Android AAR bindings, Google and AndroidX Android - dependencies as NuGet package references, and MAUI example flows matching + xcframework / Android AAR bindings, every store's Android AAR picked at + app build time, Play Services and AndroidX as NuGet package references, + and MAUI example flows matching `expo-iap`. ## Deprecations and major-version migration diff --git a/packages/docs/sponsor-registry.json b/packages/docs/sponsor-registry.json index 2ebb82737..75487b465 100644 --- a/packages/docs/sponsor-registry.json +++ b/packages/docs/sponsor-registry.json @@ -55,6 +55,68 @@ "openCollectiveSlug": "openiap", "openCollectiveImageCache": "20260706", "paypalUrl": "https://www.paypal.me/dooboolab", - "companyContactEmail": "hyo@hyo.dev" + "companyContactEmail": "hyo@hyo.dev", + "tiers": { + "source": "https://github.com/sponsors/hyodotdev", + "monthly": [ + { + "name": "Community", + "usd": 25, + "includes": "Support OpenIAP as an individual" + }, + { + "name": "Bronze", + "usd": 100, + "includes": "Name in the OpenIAP sponsors section" + }, + { + "name": "Silver", + "usd": 300, + "includes": "Company logo in the OpenIAP sponsors section" + }, + { + "name": "Gold", + "usd": 500, + "includes": "Larger logo placement and recognition" + }, + { + "name": "Angel", + "usd": 1000, + "includes": "Featured across OpenIAP repositories and sponsor pages, and priority awareness of issues affecting your platform" + }, + { + "name": "Partner", + "usd": 2000, + "includes": "Prominent placement, and priority triage for production-impacting issues" + }, + { + "name": "Strategic", + "usd": 5000, + "includes": "Top placement, and priority collaboration on roadmap and platform planning; contact the project lead before sponsoring" + } + ], + "oneTime": [ + { + "name": "One-time Support", + "usd": 100, + "includes": "Maintenance, documentation and platform stability" + }, + { + "name": "Project Boost", + "usd": 500, + "includes": "Focused maintenance and compatibility work" + }, + { + "name": "Production Boost", + "usd": 1000, + "includes": "Release stability and platform maintenance" + }, + { + "name": "Engineering Support", + "usd": 3000, + "includes": "Targeted maintenance, compatibility work or platform investigation; custom implementation is scoped separately" + } + ] + } } } diff --git a/packages/docs/src/components/CodeBlock.tsx b/packages/docs/src/components/CodeBlock.tsx index dc67be281..1b8293499 100644 --- a/packages/docs/src/components/CodeBlock.tsx +++ b/packages/docs/src/components/CodeBlock.tsx @@ -470,7 +470,7 @@ function highlightCode(element: HTMLElement, language: string) { // Opening/closing tags with attributes result = result.replace( /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)(.*?)(>)/g, - (_match, open, tag, attrs, close) => { + (_match, open: string, tag: string, attrs: string, close: string) => { let tagHtml = '' + open + ''; tagHtml += '' + tag + ''; @@ -880,7 +880,13 @@ function highlightCode(element: HTMLElement, language: string) { return fieldProcessed.replace( /:\s*(\[?)([A-Za-z_][A-Za-z0-9_]*)(\]?)(!?)/g, - (_match, bracket1, type, bracket2, exclaim) => { + ( + _match, + bracket1: string, + type: string, + bracket2: string, + exclaim: string + ) => { let result = ': '; if (bracket1) result += '['; diff --git a/packages/docs/src/components/CommerceArchitectureModal.tsx b/packages/docs/src/components/CommerceArchitectureModal.tsx index 05ad16d59..af2f4212a 100644 --- a/packages/docs/src/components/CommerceArchitectureModal.tsx +++ b/packages/docs/src/components/CommerceArchitectureModal.tsx @@ -115,7 +115,7 @@ function CommerceArchitectureModal(): React.JSX.Element { ) return; event.preventDefault(); - close(() => navigate(part.reference)); + close(() => void navigate(part.reference)); }} > {part.referenceLabel} diff --git a/packages/docs/src/components/CommerceBuildWalkthrough.tsx b/packages/docs/src/components/CommerceBuildWalkthrough.tsx index 53fcdf04a..3c73221b2 100644 --- a/packages/docs/src/components/CommerceBuildWalkthrough.tsx +++ b/packages/docs/src/components/CommerceBuildWalkthrough.tsx @@ -18,10 +18,13 @@ import { UserRound, UserRoundX, } from 'lucide-react'; -import { Link, useNavigationType } from 'react-router-dom'; +import { Link, NavigationType, useNavigationType } from 'react-router-dom'; import CodeBlock from './CodeBlock'; import CommerceImplementationComparison from './CommerceImplementationComparison'; -import { COMMERCE_PROTOCOL_LINKS } from '../lib/config'; +import { + COMMERCE_PROTOCOL_INSTALL, + COMMERCE_PROTOCOL_LINKS, +} from '../lib/config'; import { COMMERCE_IMPLEMENTATIONS, COMMERCE_IMPLEMENTATION_TOPICS, @@ -304,7 +307,7 @@ function requestCommand(request: ExampleRequest): string { } function responseValue(body: unknown, path: string): string { - let value = Array.isArray(body) ? body[0] : body; + let value: unknown = Array.isArray(body) ? body[0] : body; for (const key of path.split('.')) { value = value && typeof value === 'object' @@ -383,7 +386,7 @@ function CommerceBuildWalkthrough({ tabsRef.current?.scrollIntoView({ block: 'start', behavior: - navigationType === 'POP' || + navigationType === NavigationType.Pop || window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', @@ -1038,7 +1041,7 @@ function CommerceBuildWalkthrough({

Recorded {run.recordedAt.slice(0, 10)} · {run.checks.length}{' '} - checks in the completed example · openiap-commerce-protocol@ + checks in the completed example · {COMMERCE_PROTOCOL_INSTALL}@ {run.standalone.version}.

diff --git a/packages/docs/src/components/CommerceProtocolDiagram.tsx b/packages/docs/src/components/CommerceProtocolDiagram.tsx index d8328c941..8fc2e36ab 100644 --- a/packages/docs/src/components/CommerceProtocolDiagram.tsx +++ b/packages/docs/src/components/CommerceProtocolDiagram.tsx @@ -121,7 +121,7 @@ function CommerceProtocolDiagram({ part: PARTS[id as PartId], onShowExample, onClose: () => - navigate('#architecture', { + void navigate('#architecture', { replace: true, state: { commerceKeepScroll: true }, }), @@ -141,7 +141,7 @@ function CommerceProtocolDiagram({ aria-controls="commerce-architecture-detail" onClick={(event) => { event.currentTarget.focus({ preventScroll: true }); - navigate(`#architecture-${id}`); + void navigate(`#architecture-${id}`); }} > {icon} diff --git a/packages/docs/src/components/CommercePurchaseJourney.tsx b/packages/docs/src/components/CommercePurchaseJourney.tsx index ce46c8f75..6c1850be4 100644 --- a/packages/docs/src/components/CommercePurchaseJourney.tsx +++ b/packages/docs/src/components/CommercePurchaseJourney.tsx @@ -1,5 +1,8 @@ import { useEffect, useRef } from 'react'; -import { COMMERCE_STORE_LABELS } from '../lib/commerceImplementations'; +import { + COMMERCE_STORE_LABELS, + isCommerceStore, +} from '../lib/commerceImplementations'; import { Link, useLocation } from 'react-router-dom'; import { ArrowLeft, @@ -127,7 +130,7 @@ const STEPS = [ label: 'Keep it current', title: 'Cancellation keeps the paid time.', description: - 'Alice turns off renewal. She keeps Premium until the end of her paid period. When that deadline arrives, access ends—even if a store notification is late. Your backend reads the current answer when it needs to authorize her.', + 'Alice turns off renewal. When her paid period ends, access ends—even if a store notification is late. Your backend reads the current answer when it needs to authorize her.', nodes: [ { kind: 'store', @@ -180,7 +183,7 @@ const STEPS = [ 'Keep unfinished deletion requests until the provider confirms completion. Your receiver must discard late events for the deleted account, including after restart. This removes account identity; it does not cancel the store subscription.', term: 'Account erasure', definition: - 'Removing the link to the app user from provider records and copies held by your services. An accepted job can still be queued; wait for completed before considering provider cleanup finished.', + 'Removing the link to the app user from provider records and copies held by your services. An accepted job can still be queued. Repeat the same erasure request to read its status; provider cleanup is finished at completed.', reference: '/commerce-protocol/operations#eraseUser', referenceLabel: 'Erasure reference', }, @@ -197,12 +200,7 @@ const ICONS = { export default function CommercePurchaseJourney(): React.JSX.Element { const { hash, search } = useLocation(); const requestedStore = new URLSearchParams(search).get('store'); - const store = - requestedStore === 'amazon' || - requestedStore === 'horizon' || - requestedStore === 'google' - ? requestedStore - : 'apple'; + const store = isCommerceStore(requestedStore) ? requestedStore : 'apple'; const storeLabel = COMMERCE_STORE_LABELS[store]; const recheck = store === 'amazon' || store === 'horizon'; const steps = STEPS.map((entry) => { @@ -221,7 +219,7 @@ export default function CommercePurchaseJourney(): React.JSX.Element { return { ...entry, title: 'Check whether Alice still owns Premium.', - description: `Your backend requests Alice’s entitlements. The provider asks ${storeLabel} again using the saved store identity and purchase evidence. A negative answer removes Premium from the result. A store outage fails the request so your app can apply its retry policy.`, + description: `Your backend requests Alice’s entitlements. The provider asks ${storeLabel} again using the saved store identity and purchase evidence. A negative answer removes Premium from the result. A store outage fails the request with VERIFICATION_FAILED so your app can apply its retry policy.`, nodes: [ { kind: 'server' as const, diff --git a/packages/docs/src/components/LanguageTabs.tsx b/packages/docs/src/components/LanguageTabs.tsx index debad03ff..734034e49 100644 --- a/packages/docs/src/components/LanguageTabs.tsx +++ b/packages/docs/src/components/LanguageTabs.tsx @@ -1,5 +1,6 @@ import { useSyncExternalStore, type ReactElement, type ReactNode } from 'react'; -import StaticExamples, { useStaticExamples } from './StaticExamples'; +import StaticExamples from './StaticExamples'; +import { useStaticExamples } from '../hooks/useStaticExamples'; import { CODE_LANGUAGES, DEFAULT_CODE_LANGUAGE, diff --git a/packages/docs/src/components/MenuDropdown.tsx b/packages/docs/src/components/MenuDropdown.tsx index 62c7d5220..aae87c323 100644 --- a/packages/docs/src/components/MenuDropdown.tsx +++ b/packages/docs/src/components/MenuDropdown.tsx @@ -14,7 +14,7 @@ export interface MenuGroup { export type MenuEntry = MenuItem | MenuGroup; function isGroup(entry: MenuEntry): entry is MenuGroup { - return 'items' in entry && Array.isArray((entry as MenuGroup).items); + return 'items' in entry && Array.isArray(entry.items); } interface MenuDropdownProps { @@ -161,7 +161,7 @@ export function MenuDropdown({ const handleTitleClick = () => { if (!titleTo) return; setIsExpanded(true); - navigate(titleTo); + void navigate(titleTo); onItemClick?.(); }; diff --git a/packages/docs/src/components/PlatformTabs.tsx b/packages/docs/src/components/PlatformTabs.tsx index a670de515..542f8b552 100644 --- a/packages/docs/src/components/PlatformTabs.tsx +++ b/packages/docs/src/components/PlatformTabs.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, ReactNode } from 'react'; -import StaticExamples, { useStaticExamples } from './StaticExamples'; +import StaticExamples from './StaticExamples'; +import { useStaticExamples } from '../hooks/useStaticExamples'; type Platform = 'ios' | 'android' | 'amazon' | 'horizon'; @@ -32,14 +33,16 @@ function platformFromHash(availablePlatforms: Platform[]): Platform | null { function PlatformTabs({ children }: PlatformTabsProps) { const isStatic = useStaticExamples(); + // children is a new object each render; key the list on which platforms exist. + const platformKey = PLATFORM_ORDER.filter( + (platform) => children[platform] !== undefined + ).join(' '); const availablePlatforms = useMemo( - () => PLATFORM_ORDER.filter((platform) => children[platform] !== undefined), - [ - children.ios !== undefined, - children.android !== undefined, - children.horizon !== undefined, - children.amazon !== undefined, - ] + () => + PLATFORM_ORDER.filter((platform) => + platformKey.split(' ').includes(platform) + ), + [platformKey] ); const [activeTab, setActiveTab] = useState( diff --git a/packages/docs/src/components/SearchModal.tsx b/packages/docs/src/components/SearchModal.tsx index 909ac9f45..fe0a28f4f 100644 --- a/packages/docs/src/components/SearchModal.tsx +++ b/packages/docs/src/components/SearchModal.tsx @@ -49,7 +49,7 @@ function SearchModal({ isOpen, onClose }: SearchModalProps) { const handleApiSelect = useCallback( (api: ApiItem) => { - navigate(api.path); + void navigate(api.path); onClose(); }, [navigate, onClose] diff --git a/packages/docs/src/components/ShowcaseCards.tsx b/packages/docs/src/components/ShowcaseCards.tsx index 28cddcc8b..99c1a7f3b 100644 --- a/packages/docs/src/components/ShowcaseCards.tsx +++ b/packages/docs/src/components/ShowcaseCards.tsx @@ -9,12 +9,6 @@ export const SHOWCASE_DISCUSSION_URL = export const SHOWCASE_GUIDE_URL = 'https://github.com/hyodotdev/openiap/blob/main/packages/docs/SHOWCASE.md'; -export const showcaseGridStyle: CSSProperties = { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', - gap: '1rem', -}; - const cardStyle: CSSProperties = { display: 'flex', gap: '1rem', diff --git a/packages/docs/src/components/StaticExamples.tsx b/packages/docs/src/components/StaticExamples.tsx index 2dd599eb4..773955764 100644 --- a/packages/docs/src/components/StaticExamples.tsx +++ b/packages/docs/src/components/StaticExamples.tsx @@ -1,16 +1,5 @@ -import { - createContext, - useContext, - type ReactElement, - type ReactNode, -} from 'react'; - -// Nested tabs share one fallback so noscript elements never nest. -const StaticExamplesContext = createContext(false); - -export function useStaticExamples(): boolean { - return useContext(StaticExamplesContext); -} +import type { ReactElement, ReactNode } from 'react'; +import { StaticExamplesContext } from '../hooks/useStaticExamples'; export default function StaticExamples({ children, diff --git a/packages/docs/src/hooks/useScrollToHash.ts b/packages/docs/src/hooks/useScrollToHash.ts index bde13f6d3..3da09b3c0 100644 --- a/packages/docs/src/hooks/useScrollToHash.ts +++ b/packages/docs/src/hooks/useScrollToHash.ts @@ -1,11 +1,21 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; +// Commerce links that swap a step in place pass this state to keep the scroll. +export function keepsScroll(state: unknown): boolean { + return ( + typeof state === 'object' && + state !== null && + 'commerceKeepScroll' in state && + state.commerceKeepScroll === true + ); +} + export function useScrollToHash(offset = 80) { const location = useLocation(); useEffect(() => { - if (location.state?.commerceKeepScroll) return; + if (keepsScroll(location.state)) return; // Always scroll to top first when route changes if (!location.hash) { diff --git a/packages/docs/src/hooks/useStaticExamples.ts b/packages/docs/src/hooks/useStaticExamples.ts new file mode 100644 index 000000000..6d0f447dd --- /dev/null +++ b/packages/docs/src/hooks/useStaticExamples.ts @@ -0,0 +1,8 @@ +import { createContext, useContext } from 'react'; + +// Nested tabs share one fallback so noscript elements never nest. +export const StaticExamplesContext = createContext(false); + +export function useStaticExamples(): boolean { + return useContext(StaticExamplesContext); +} diff --git a/packages/docs/src/lib/commerceImplementations.ts b/packages/docs/src/lib/commerceImplementations.ts index 6557644b8..a8a0fef12 100644 --- a/packages/docs/src/lib/commerceImplementations.ts +++ b/packages/docs/src/lib/commerceImplementations.ts @@ -10,7 +10,11 @@ export const COMMERCE_STORE_LABELS: Record = { }; export function isCommerceStore(value: string | null): value is CommerceStore { - return value !== null && value in COMMERCE_STORE_LABELS; + // Own keys only: `in` also accepts inherited names such as "constructor". + return ( + value !== null && + Object.prototype.hasOwnProperty.call(COMMERCE_STORE_LABELS, value) + ); } export const COMMERCE_IMPLEMENTATIONS = { @@ -127,7 +131,7 @@ export const COMMERCE_IMPLEMENTATION_TOPICS = { }, kit: { description: - 'IAPKit rechecks every linked Amazon or Horizon purchase from its own rate budget, then rereads ownership after those calls. A rejected product is removed from productIds, and so is one bound after the recheck pass; an outage fails the read; an exhausted budget answers RATE_LIMITED. These products have no invented subscription records.', + 'IAPKit rechecks every linked Amazon or Horizon purchase from its own rate budget, then rereads ownership after those calls. A rejected product is removed from productIds, and so is one bound after the recheck pass; an outage fails the read with VERIFICATION_FAILED; an exhausted budget answers RATE_LIMITED. These products have no invented subscription records.', file: 'convex/purchases/action.ts', symbol: 'readBoundPurchaseEntitlements', line: 19, diff --git a/packages/docs/src/lib/searchData.ts b/packages/docs/src/lib/searchData.ts index bf2556fad..a48a3331f 100644 --- a/packages/docs/src/lib/searchData.ts +++ b/packages/docs/src/lib/searchData.ts @@ -335,7 +335,7 @@ export const apiData: ApiItem[] = [ id: 'present-external-purchase-link-ios', title: 'presentExternalPurchaseLinkIOS', category: 'iOS Specific', - description: 'Open the external purchase URL in Safari (iOS 18.2+)', + description: 'Open the external purchase URL in Safari (iOS 16+)', parameters: 'url: String!', returns: 'ExternalPurchaseLinkResultIOS!', path: '/docs/apis/ios/present-external-purchase-link-ios', @@ -506,7 +506,7 @@ export const apiData: ApiItem[] = [ title: 'External Purchase', category: 'Documentation', description: - 'External purchase links for iOS - redirect users to external payment websites (iOS 16.0+)', + 'External purchase links for iOS - redirect users to external payment websites (iOS 17.4+)', path: '/docs/features/external-purchase', }, { diff --git a/packages/docs/src/lib/sponsors.tsx b/packages/docs/src/lib/sponsors.tsx index bf86c8e35..7efd79fc4 100644 --- a/packages/docs/src/lib/sponsors.tsx +++ b/packages/docs/src/lib/sponsors.tsx @@ -98,6 +98,19 @@ export const META_SUPPORTER = requireSupporter('meta'); const openCollectiveUrl = `https://opencollective.com/${sponsorRegistry.funding.openCollectiveSlug}`; +export interface SponsorTier { + name: string; + usd: number; + includes: string; +} + +/** Mirrors the tiers on GitHub Sponsors (`tiers.source`). */ +export const SPONSOR_TIERS: { + source: string; + monthly: readonly SponsorTier[]; + oneTime: readonly SponsorTier[]; +} = sponsorRegistry.funding.tiers; + export const FUNDING_LINKS = { ...sponsorRegistry.funding, companyContactUrl: `mailto:${sponsorRegistry.funding.companyContactEmail}`, diff --git a/packages/docs/src/pages/commerce-protocol/authentication.tsx b/packages/docs/src/pages/commerce-protocol/authentication.tsx index af3a82e88..3a42f2b4e 100644 --- a/packages/docs/src/pages/commerce-protocol/authentication.tsx +++ b/packages/docs/src/pages/commerce-protocol/authentication.tsx @@ -102,11 +102,12 @@ function CommerceAuthentication() {

Providers issue their own credentials; the protocol does not impose a - key format. Credentials travel in the Authorization{' '} - header and never in a URL. A protected request without a credential - returns UNAUTHORIZED; a credential with the wrong role - returns FORBIDDEN. Separate credentials keep a shipped - app from looking up or changing other users’ accounts. Full rules:{' '} + key format. A credential travels as{' '} + Authorization: Bearer <credential>, never in a URL. + A protected request without a credential returns{' '} + UNAUTHORIZED; a credential with the wrong role returns{' '} + FORBIDDEN. Separate credentials keep a shipped app from + looking up or changing other users’ accounts. Full rules:{' '} @@ -97,13 +66,25 @@ function CommerceConformance() { contract installation guide {' '} - and add ajv with your package manager. Save this as{' '} - check-conformance.mjs - and run it with Node.js or Bun. The built-in adapters add the Bearer - prefix: supply token values without that prefix. Set - COMMERCE_GRAPHQL_URL only when testing that binding. + and add ajv with your package manager. Save the script + in{' '} + + SPEC.md §11.2 + {' '} + as check-conformance.mjs and run it with Node.js or + Bun. Set COMMERCE_BASE_URL,{' '} + COMMERCE_VERIFICATION_TOKEN, and{' '} + COMMERCE_SERVER_TOKEN as in{' '} + + Connect to a provider + + , and COMMERCE_GRAPHQL_URL only when testing that + binding. The adapters send each credential as a Bearer token.

- {RUNNER_SNIPPET}

The runner talks only through the fetch you give it; the URL may point to a local test provider. A provider whose diff --git a/packages/docs/src/pages/commerce-protocol/ecosystem.tsx b/packages/docs/src/pages/commerce-protocol/ecosystem.tsx index ecb93a373..6872f4c35 100644 --- a/packages/docs/src/pages/commerce-protocol/ecosystem.tsx +++ b/packages/docs/src/pages/commerce-protocol/ecosystem.tsx @@ -162,12 +162,6 @@ function CommerceEcosystemGuide(): React.JSX.Element { changes service ownership and configuration, while the store purchase flow and commerce payload meanings stay the same.

-

- These technical roles describe interoperability. Joint development, - support commitments, and promotion depend on separately agreed - contributions. The open contract does not include free hosting; IAPKit - and other providers set their own service terms. -

Profile obligations ·{' '} Conformance runner ·{' '} @@ -192,9 +186,7 @@ function CommerceEcosystemGuide(): React.JSX.Element {

Propose shared behavior with a concrete use case, a runnable example, a rejection case, and its compatibility impact. Describe which - implementers need to change. OpenIAP remains founder-led today; - proposals and decisions are public, and IAPKit follows the same - contract and checks as other implementations. + implementers need to change.

diff --git a/packages/docs/src/pages/commerce-protocol/getting-started.tsx b/packages/docs/src/pages/commerce-protocol/getting-started.tsx index a7a9a20da..47975e090 100644 --- a/packages/docs/src/pages/commerce-protocol/getting-started.tsx +++ b/packages/docs/src/pages/commerce-protocol/getting-started.tsx @@ -9,14 +9,44 @@ import CodeBlock from '../../components/CodeBlock'; import PackageInstall from '../../components/PackageInstall'; import SEO from '../../components/SEO'; import CommercePurchaseJourney from '../../components/CommercePurchaseJourney'; -import { COMMERCE_IMPLEMENTATIONS } from '../../lib/commerceImplementations'; +import { + COMMERCE_IMPLEMENTATIONS, + COMMERCE_STORE_LABELS, + isCommerceStore, + type CommerceStore, +} from '../../lib/commerceImplementations'; import { useScrollToHash } from '../../hooks/useScrollToHash'; +const EVIDENCE: Record = { + apple: { + request: evidence, + note: 'Replace the illustrative JWS with the StoreKit transaction JWS the app received.', + }, + google: { + request: { store: 'google', google: { purchaseToken: '…' } }, + note: 'Use the purchase token Google Play returned to the app.', + }, + amazon: { + request: { + store: 'amazon', + amazon: { userId: '…', receiptId: '…', sandbox: true }, + }, + note: 'Use the Amazon user ID and receipt ID from the purchase response, and keep sandbox only for App Tester receipts. Before binding, confirm the Amazon user belongs to the signed-in session.', + }, + horizon: { + request: { store: 'horizon', horizon: { userId: '…', sku: '…' } }, + note: 'Use the Meta user ID and the add-on SKU. Before binding, confirm the Meta user belongs to the signed-in session.', + }, +}; + function CommerceGettingStarted(): React.JSX.Element { useScrollToHash(); const { search } = useLocation(); - const pathOf = (name: string): string => - httpBinding.operations.find((operation) => operation.name === name)!.path; + const requestedStore = new URLSearchParams(search).get('store'); + const store = isCommerceStore(requestedStore) ? requestedStore : 'apple'; + const operationOf = (name: string): (typeof httpBinding.operations)[number] => + httpBinding.operations.find((operation) => operation.name === name)!; + const pathOf = (name: string): string => operationOf(name).path; return (

@@ -41,10 +71,12 @@ function CommerceGettingStarted(): React.JSX.Element { implementation’s code when you need it.

- Apple and Google subscription changes arrive through store - notifications. Amazon and Horizon access is rechecked with the store - when your backend requests it. Choose your store below to follow the - matching path. + In IAPKit and the example, Apple and Google subscription changes arrive + through store notifications, and Amazon and Horizon ownership is + rechecked with the store on each entitlements read. Another provider can + work differently; its{' '} + capabilities say what + it supports. Choose your store below to follow the matching path.

@@ -60,75 +92,8 @@ function CommerceGettingStarted(): React.JSX.Element { Choose your services and build it →

-
- Implementation reference: requests and receiver setup -

- Use these details when wiring a backend or checking your AI’s code. -

-
- - Receive subscription changes - -

- Connect an existing backend with the ready receiver. It verifies the - signature, validates the event, and saves it once in SQLite. You do - not need to build a commerce provider or a store adapter. -

-

- If your service only consumes events, start here. The purchase and - access operations below belong to the app backend and its chosen - provider; your service can keep its existing backend. -

-

- The example repository{' '} - includes the receiver and executable checks. AI can reuse it to - demonstrate signed delivery, retries, tamper rejection, and - preserved inbox entries after reopening storage. -

-
- Run the receiver example locally -

- Clone the repository, install Bun for its runtime, then install - dependencies and run the demo. This fixture check requires no - credentials or external services. -

- - {`npm run demo:consumer`} -

- - Recorded results and payloads - -

-
-
- Connect the receiver to your provider -

- Set COMMERCE_WEBHOOK_SECRET to your provider’s - signing secret, then run npm run consumer. The ready - endpoint is - http://127.0.0.1:5182/webhooks/commerce. Put it - behind your HTTPS reverse proxy, forwarding the exact body bytes - to that local address, and register the HTTPS URL with your - provider. The receiver accepts the public Host header forwarded by - the proxy; its signature check authenticates the sender. -

-

- Use one emitter/project and signing key per receiver database. - consumer.sqlite is the durable inbox; set - COMMERCE_INBOX_PATH for your persistent storage - path. To embed the same Fetch-compatible handler in your server, - use - createReceiver from webhooks.mjs. -

-

- The inbox preserves the signed event for your existing processing - pipeline. Transaction and price fields are optional; a missing - price is unknown. Each lifecycle event is not necessarily a new - charge. Keep financial calculations in your business logic. -

-
-
- +
+

Use these steps when wiring a backend or checking your AI’s code.

1. Install the contract @@ -173,16 +138,18 @@ function CommerceGettingStarted(): React.JSX.Element { 2. Connect to a provider

- Obtain the base URL, supported store configuration, and - Authorization header values from your provider. A provider issues - its own credentials; OpenIAP has no registration service. Keep the + Obtain the base URL, supported store configuration, and the + verification and server credentials from your provider. A provider + issues its own credentials; OpenIAP has no registration service. + Each request sends one as{' '} + Authorization: Bearer <credential>. Keep the server credential in your backend. These shell examples assume{' '} curl and jq.

{`export COMMERCE_BASE_URL='https://your-provider.example' # Set these through your local secret manager or environment. -# COMMERCE_VERIFY_AUTH: complete Authorization header value for verification -# COMMERCE_SERVER_AUTH: complete Authorization header value for the server role +# COMMERCE_VERIFICATION_TOKEN: the verification credential +# COMMERCE_SERVER_TOKEN: the server credential curl --fail-with-body "$COMMERCE_BASE_URL${pathOf('providerCapabilities')}"`}

@@ -202,20 +169,16 @@ curl --fail-with-body "$COMMERCE_BASE_URL${pathOf('providerCapabilities')}"`}

- Save the following shape as evidence.json. Replace its - illustrative JWS with a real StoreKit transaction JWS from the app. - For Google, use{' '} - - {'{ "store": "google", "google": { "purchaseToken": "…" } }'} - - . Your provider must be configured for that app and store - environment. + Save the {COMMERCE_STORE_LABELS[store]} evidence below as{' '} + evidence.json; it follows the store chosen above.{' '} + {EVIDENCE[store].note} Your provider must be configured for that app + and store environment.

- {JSON.stringify(evidence, null, 2)} + {JSON.stringify(EVIDENCE[store].request, null, 2)} {`curl --fail-with-body "$COMMERCE_BASE_URL${pathOf('verifyPurchase')}" \\ - -H "Authorization: $COMMERCE_VERIFY_AUTH" \\ + -H "Authorization: Bearer $COMMERCE_VERIFICATION_TOKEN" \\ -H 'Content-Type: application/json' \\ --data-binary @evidence.json`}

@@ -238,19 +201,20 @@ jq --arg userId "$COMMERCE_USER_ID" '. + {userId: $userId}' \\ evidence.json > binding.json curl --fail-with-body "$COMMERCE_BASE_URL${pathOf('bindPurchase')}" \\ - -H "Authorization: $COMMERCE_SERVER_AUTH" \\ + -H "Authorization: Bearer $COMMERCE_SERVER_TOKEN" \\ -H 'Content-Type: application/json' \\ --data-binary @binding.json`}

- Continue after bound: true. A bound: false - result intentionally does not distinguish unknown evidence from a - purchase belonging to someone else. Do not transfer the binding or - grant access on that result; use your provider’s recovery process. - The association can remain bound even when the subscription is - expired. + Continue after bound: true. For a store the provider + integrates, a bound: false result intentionally does + not distinguish unknown evidence from a purchase belonging to + someone else; other stores fail with UNSUPPORTED_STORE instead. Do + not transfer the binding or grant access on that result; use your + provider’s recovery process. The association can remain bound even + when the subscription is expired.

{`curl --fail-with-body --get "$COMMERCE_BASE_URL${pathOf('entitlements')}" \\ - -H "Authorization: $COMMERCE_SERVER_AUTH" \\ + -H "Authorization: Bearer $COMMERCE_SERVER_TOKEN" \\ --data-urlencode "userId=$COMMERCE_USER_ID"`}

Gate product-specific features on membership in{' '} @@ -278,18 +242,20 @@ curl --fail-with-body "$COMMERCE_BASE_URL${pathOf('bindPurchase')}" \\ backend destination using its management interface and exchange a webhook secret. Destination registration is provider-specific. Follow the{' '} - - backend architecture + + receiver steps {' '} to authenticate and persist each delivery before acknowledging it.

Use events to trigger an authoritative entitlement refresh when you cannot safely correlate purchases, especially across multiple - subscriptions or product changes. A cancellation disables renewal; - it does not automatically remove the remaining paid access. Without - events, use bounded server reads at the points your application - needs a current answer. + subscriptions or product changes. Without events, use bounded server + reads at the points your application needs a current answer.{' '} + + SPEC.md §2.3 + {' '} + defines when a subscription grants access.

When an authenticated user deletes their account, call{' '} @@ -300,6 +266,20 @@ curl --fail-with-body "$COMMERCE_BASE_URL${pathOf('bindPurchase')}" \\ arrange erasure separately for copies already delivered to your backend and connected services.

+ {`jq -n --arg userId "$COMMERCE_USER_ID" '{userId: $userId}' > erasure.json + +curl --fail-with-body "$COMMERCE_BASE_URL${pathOf('eraseUser')}" \\ + -H "Authorization: Bearer $COMMERCE_SERVER_TOKEN" \\ + -H 'Content-Type: application/json' \\ + --data-binary @erasure.json`} +

+ A {operationOf('eraseUser').successStatus} with{' '} + accepted: true acknowledges the request. When the + answer carries a job status, send the same request + again to read the job's progress: repeating it is safe and + reports the current job. The provider's cleanup is finished at{' '} + completed. +

Continue with the{' '} operation reference,{' '} @@ -310,7 +290,71 @@ curl --fail-with-body "$COMMERCE_BASE_URL${pathOf('bindPurchase')}" \\ .

-
+ +
+ + Receive subscription changes + +

+ Connect an existing backend with the ready receiver. It verifies the + signature, validates the event, and saves it once in SQLite. You do + not need to build a commerce provider or a store adapter. +

+

+ If your service only consumes events, this section is all you need. + The purchase and access operations above belong to the app backend + and its chosen provider; your service can keep its existing backend. +

+

+ The example repository{' '} + includes the receiver and executable checks. AI can reuse it to + demonstrate signed delivery, retries, tamper rejection, and + preserved inbox entries after reopening storage. +

+
+ Run the receiver example locally +

+ Clone the repository, install Bun for its runtime, then install + dependencies and run the demo. This fixture check requires no + credentials or external services. +

+ + {`npm run demo:consumer`} +

+ + Recorded results and payloads + +

+
+
+ Connect the receiver to your provider +

+ Set COMMERCE_WEBHOOK_SECRET to your provider’s + signing secret, then run npm run consumer. The ready + endpoint is + http://127.0.0.1:5182/webhooks/commerce. Put it + behind your HTTPS reverse proxy, forwarding the exact body bytes + to that local address, and register the HTTPS URL with your + provider. The receiver accepts the public Host header forwarded by + the proxy; its signature check authenticates the sender. +

+

+ Use one emitter/project and signing key per receiver database. + consumer.sqlite is the durable inbox; set + COMMERCE_INBOX_PATH for your persistent storage + path. To embed the same Fetch-compatible handler in your server, + use + createReceiver from webhooks.mjs. +

+

+ The inbox preserves the signed event for your existing processing + pipeline. Transaction and price fields are optional; a missing + price is unknown. Each lifecycle event is not necessarily a new + charge. Keep financial calculations in your business logic. +

+
+
+
); } diff --git a/packages/docs/src/pages/commerce-protocol/graphql.tsx b/packages/docs/src/pages/commerce-protocol/graphql.tsx index 0881eb99d..5bba1f848 100644 --- a/packages/docs/src/pages/commerce-protocol/graphql.tsx +++ b/packages/docs/src/pages/commerce-protocol/graphql.tsx @@ -36,10 +36,13 @@ function CommerceGraphql() { for a provider.

- The same six operations at one POST endpoint, whose path - each provider documents, executing exactly the generated schema - projection (generated/bindings/operations.graphql). The - same authentication and account rules apply to both bindings. + It serves the same six operations at one POST endpoint, + whose path each provider documents. The served schema defines everything + in the generated projection ( + generated/bindings/operations.graphql) exactly as the + projection defines it; a newer compatible MINOR may add to it but never + change it. The same authentication and account rules apply to both + bindings.

@@ -62,9 +65,9 @@ function CommerceGraphql() { 2 )} - {`# Use your provider's GraphQL URL and complete server Authorization header. + {`# COMMERCE_GRAPHQL_URL is your provider's GraphQL endpoint. curl --fail-with-body "$COMMERCE_GRAPHQL_URL" \\ - -H "Authorization: $COMMERCE_SERVER_AUTH" \\ + -H "Authorization: Bearer $COMMERCE_SERVER_TOKEN" \\ -H 'Content-Type: application/json' \\ --data-binary @request.json`}

diff --git a/packages/docs/src/pages/commerce-protocol/operations.tsx b/packages/docs/src/pages/commerce-protocol/operations.tsx index ee7527121..5dcffae14 100644 --- a/packages/docs/src/pages/commerce-protocol/operations.tsx +++ b/packages/docs/src/pages/commerce-protocol/operations.tsx @@ -30,7 +30,7 @@ const EXPLANATIONS = [ description: 'Your authenticated backend selects Alice and asks the provider to associate the verified purchase with her. Repeating this request keeps the same owner.', result: - 'Continue on bound: true. A false result covers both unknown evidence and an ownership conflict; it never tells Bob whose purchase exists.', + 'Continue on bound: true. For a store the provider integrates, a false result covers both unknown evidence and an ownership conflict; it never tells Bob whose purchase exists. Other stores fail with UNSUPPORTED_STORE instead.', }, { name: 'entitlements', @@ -57,7 +57,7 @@ const EXPLANATIONS = [ description: 'When Alice deletes her account, ask the provider to remove her identity from its subscription records and protocol event store. Repeating the request is safe.', result: - 'accepted acknowledges the request. A provider may report an erasure job. Your backend and other event recipients must erase their own copies separately.', + 'accepted acknowledges the request. A provider may report an erasure job; repeat the same request to read its status until completed. Your backend and other event recipients must erase their own copies separately.', }, ] as const; @@ -114,6 +114,10 @@ export default function CommerceOperations(): React.JSX.Element { {operation.method} {operation.path} +

Success status
+
+ {operation.successStatus} +
Responsibility
{operation.profile === 'core' ? ( diff --git a/packages/docs/src/pages/commerce-protocol/overview.tsx b/packages/docs/src/pages/commerce-protocol/overview.tsx index cc3338fa4..308073aaa 100644 --- a/packages/docs/src/pages/commerce-protocol/overview.tsx +++ b/packages/docs/src/pages/commerce-protocol/overview.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect } from 'react'; import { Link, + NavigationType, useLocation, useNavigate, useNavigationType, @@ -10,11 +11,14 @@ import SEO from '../../components/SEO'; import CommerceEcosystem from '../../components/CommerceEcosystem'; import CommerceProtocolDiagram from '../../components/CommerceProtocolDiagram'; import CommerceBuildWalkthrough from '../../components/CommerceBuildWalkthrough'; +import { keepsScroll } from '../../hooks/useScrollToHash'; import { COMMERCE_PROTOCOL_LINKS } from '../../lib/config'; import '../../styles/commerce-protocol.css'; function CommerceProtocol(): React.JSX.Element { - const { hash, state } = useLocation(); + const location = useLocation(); + const { hash } = location; + const keepScroll = keepsScroll(location.state); const navigate = useNavigate(); const navigationType = useNavigationType(); const stepMatch = /^#build-step-([1-9]\d*)$/.exec(hash); @@ -27,7 +31,7 @@ function CommerceProtocol(): React.JSX.Element { : null; const showExample = useCallback( (step: number): void => { - navigate(`#build-step-${step}`); + void navigate(`#build-step-${step}`); }, [navigate] ); @@ -36,7 +40,7 @@ function CommerceProtocol(): React.JSX.Element { if ( !hash || hash.startsWith('#architecture-') || - state?.commerceKeepScroll || + keepScroll || exampleStep !== null ) return; @@ -45,19 +49,19 @@ function CommerceProtocol(): React.JSX.Element { target?.scrollIntoView({ block: 'start', behavior: - navigationType === 'POP' || + navigationType === NavigationType.Pop || window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', }); }); return () => cancelAnimationFrame(frame); - }, [hash, state, exampleStep, navigationType]); + }, [hash, keepScroll, exampleStep, navigationType]); return (
@@ -71,9 +75,10 @@ function CommerceProtocol(): React.JSX.Element { Connect the whole.

- A shared contract for paywalls, commerce services, and data - platforms. Understand how the parts connect, choose what your - product owns, and give AI the contract to implement it. + A vendor-neutral specification for the server side of in-app + purchases. Understand how paywalls, commerce services, and data + platforms connect, choose what your product owns, and give AI the + contract to implement it.

@@ -114,7 +119,7 @@ function CommerceProtocol(): React.JSX.Element { const open = event.currentTarget.open; if (open && exampleStep === null) showExample(1); if (!open && exampleStep !== null) - navigate('#architecture', { + void navigate('#architecture', { replace: true, state: { commerceKeepScroll: true }, }); diff --git a/packages/docs/src/pages/commerce-protocol/profiles.tsx b/packages/docs/src/pages/commerce-protocol/profiles.tsx index 389dbd194..086ebb301 100644 --- a/packages/docs/src/pages/commerce-protocol/profiles.tsx +++ b/packages/docs/src/pages/commerce-protocol/profiles.tsx @@ -122,7 +122,8 @@ export default function CommerceProfiles(): React.JSX.Element {

The provider lists its complete profiles in{' '} capabilities. An - unfinished profile must not be advertised.{' '} + unfinished profile must not be advertised, and a call to an operation + from an unlisted profile fails with UNSUPPORTED_PROFILE.{' '} Conformance checks test the promise against the contract.

diff --git a/packages/docs/src/pages/commerce-protocol/rest.tsx b/packages/docs/src/pages/commerce-protocol/rest.tsx index 5c5a5c4b9..3233b6cbe 100644 --- a/packages/docs/src/pages/commerce-protocol/rest.tsx +++ b/packages/docs/src/pages/commerce-protocol/rest.tsx @@ -45,14 +45,14 @@ function CommerceRest() { Read current access

- Run this from your authenticated backend. Set the complete - Authorization header value in COMMERCE_SERVER_AUTH and - select COMMERCE_USER_ID from the backend's session and - ownership policy. + Run this from your authenticated backend. Set the server credential in{' '} + COMMERCE_SERVER_TOKEN and select{' '} + COMMERCE_USER_ID from the backend's session and ownership + policy.

{`export COMMERCE_BASE_URL='https://your-provider.example' curl --fail-with-body --get "$COMMERCE_BASE_URL${ENTITLEMENTS_PATH}" \\ - -H "Authorization: $COMMERCE_SERVER_AUTH" \\ + -H "Authorization: Bearer $COMMERCE_SERVER_TOKEN" \\ --data-urlencode "userId=$COMMERCE_USER_ID"`}

Read productIds for current access. An empty list grants diff --git a/packages/docs/src/pages/commerce-protocol/versioning.tsx b/packages/docs/src/pages/commerce-protocol/versioning.tsx index ea5692575..7eb38f532 100644 --- a/packages/docs/src/pages/commerce-protocol/versioning.tsx +++ b/packages/docs/src/pages/commerce-protocol/versioning.tsx @@ -1,39 +1,9 @@ import { COMMERCE_PROTOCOL_LINKS } from '../../lib/config'; import AnchorLink from '../../components/AnchorLink'; -import DataTable from '../../components/DataTable'; import SEO from '../../components/SEO'; const SPEC_URL = COMMERCE_PROTOCOL_LINKS.spec; -interface ChangeRow { - change: string; - impact: string; -} - -const CHANGE_ROWS: ChangeRow[] = [ - { - change: - 'New optional member on an open object, event type, operation, or error code', - impact: 'MINOR', - }, - { - change: 'New value in an open space (store, environment, eventType…)', - impact: 'MINOR', - }, - { - change: 'Member removed, renamed, retyped, or made required', - impact: 'MAJOR', - }, - { - change: 'Member added to a closed object or closed enumeration', - impact: 'MAJOR', - }, - { - change: 'Operation removed, or its path, method, or auth role changed', - impact: 'MAJOR', - }, -]; - function CommerceVersioning() { return (

@@ -52,42 +22,35 @@ function CommerceVersioning() {

For example, adding an optional field to an open response can be minor: - older callers ignore it. Renaming a required field is major because - those callers would no longer find the answer they expect. + older callers ignore it. Renaming a field is normally major because + those callers would no longer find the answer they expect. Before the + npm package reaches 1.0.0, a wire member can be renamed + without moving the protocol major; each such rename is listed with its + migration note.

The protocol, each profile, and each binding version independently as - MAJOR.MINOR, and callers pin on the major. Open value spaces and open - objects are what make MINOR additions safe: a consumer ignores what it + MAJOR.MINOR, and callers pin on the major. The protocol version is + separate from the npm package version. Open value spaces and open + objects are what make MINOR additions safe: a consumer tolerates what it does not recognise instead of failing.

What changes what - row.change }, - { - header: 'Impact', - cell: (row: ChangeRow) => {row.impact}, - }, - ]} - rows={CHANGE_ROWS} - rowKey={(row) => row.change} - />

- The REST path's v1 segment is the protocol major, so - two majors can be served side by side during a migration. The full - decision table is{' '} SPEC.md §12 - - . + {' '} + has the full MAJOR/MINOR decision table and the list of renames made + before 1.0.0. The REST path's v1{' '} + segment is the protocol major, so two majors can be served side by + side during a migration.

diff --git a/packages/docs/src/pages/commerce-protocol/webhooks.tsx b/packages/docs/src/pages/commerce-protocol/webhooks.tsx index 804368129..192bf0b64 100644 --- a/packages/docs/src/pages/commerce-protocol/webhooks.tsx +++ b/packages/docs/src/pages/commerce-protocol/webhooks.tsx @@ -14,6 +14,8 @@ const KNOWN_COMMERCE_EVENT_TYPES = commerceEventSchema.properties.eventType.examples; const SPEC_URL = `${COMMERCE_PROTOCOL_LINKS.spec}#94-webhook-contract`; +const ENTITLEMENT_SPEC_URL = `${COMMERCE_PROTOCOL_LINKS.spec}#23-entitlement`; +const DESTINATION_SPEC_URL = `${COMMERCE_PROTOCOL_LINKS.spec}#945-destination-safety`; const GRAPHQL_CONTRACT_URL = 'https://github.com/hyodotdev/openiap/tree/main/specs/commerce-protocol/schema'; const SIGNATURE_VECTORS_URL = @@ -135,7 +137,9 @@ function Webhooks() { The emitter sends POST to a public HTTPS URL supplied directly by the consumer. The content type is{' '} application/json, and the body is one Commerce Protocol - event document. + event document. The body is not compressed:{' '} + Content-Encoding is absent or identity, + because compression would make the exact signed bytes ambiguous.

Headers help route and inspect a delivery, but the signed body is the authority. Read eventId from the parsed body rather than - trusting the convenience header. + trusting the convenience header. A request carries exactly one{' '} + openiap-timestamp, written as a base-10 integer with no + sign.

@@ -178,6 +184,11 @@ function Webhooks() { Read the raw request bytes. Re-serializing JSON changes the signed input. +
  • + Use the shared secret's exact UTF-8 bytes as the HMAC key, + including any prefix such as whsec_. Never strip the + prefix or hex-decode the rest. +
  • Accept only when |now - timestamp| <= 300 seconds; otherwise reject it. @@ -223,11 +234,13 @@ function Webhooks() { newer state, while still processing independent idempotent effects.

    - A subscription's active value is a snapshot at - processedAt. Never grant access at or after its - expiresAt; refresh current access when needed. A - cancellation stops renewal and does not remove the remaining paid - period. See{' '} + A subscription's active value is a snapshot at{' '} + processedAt. Never grant access at or after its{' '} + expiresAt; refresh current access when needed.{' '} + + SPEC.md §2.3 + {' '} + defines the entitlement rule. See also{' '} ongoing access @@ -241,10 +254,14 @@ function Webhooks() {

    Emitters accept public HTTPS destinations only. They reject embedded - credentials and loopback, private, link-local, or unique-local - addresses; validate every resolved address; and do not follow - redirects. They connect only to a validated public address, by pinning - it or verifying the connected peer before sending bytes. + credentials and every address that is not globally routable unicast;{' '} + + SPEC.md §9.4.5 + {' '} + lists the blocked ranges, including IPv4-mapped IPv6 spellings. They + validate every resolved address, do not follow redirects, and connect + only to a validated public address, by pinning it or verifying the + connected peer before sending bytes.

    diff --git a/packages/docs/src/pages/docs/apis/get-storefront.tsx b/packages/docs/src/pages/docs/apis/get-storefront.tsx index d5ac62596..6a3483b1a 100644 --- a/packages/docs/src/pages/docs/apis/get-storefront.tsx +++ b/packages/docs/src/pages/docs/apis/get-storefront.tsx @@ -41,6 +41,14 @@ function GetStorefront() { .

    +

    + On a Meta Horizon build the billing client answers{' '} + getBillingConfigAsync with{' '} + FEATURE_NOT_SUPPORTED, so getStorefront{' '} + rejects with that code rather than returning a country. Observed on a + Quest 3 with horizon-billing-compatibility 2.0.0. Read the + region from your own backend on that store. +

    diff --git a/packages/docs/src/pages/docs/apis/index.tsx b/packages/docs/src/pages/docs/apis/index.tsx index 3703b44ab..202bca5b6 100644 --- a/packages/docs/src/pages/docs/apis/index.tsx +++ b/packages/docs/src/pages/docs/apis/index.tsx @@ -129,7 +129,7 @@ function APIsIndex() { ) { return; } - navigate(redirect, { replace: true }); + void navigate(redirect, { replace: true }); }, [location.hash, location.pathname, navigate]); return ( diff --git a/packages/docs/src/pages/docs/apis/ios/present-external-purchase-link-ios.tsx b/packages/docs/src/pages/docs/apis/ios/present-external-purchase-link-ios.tsx index 48dcebb3b..31180adb9 100644 --- a/packages/docs/src/pages/docs/apis/ios/present-external-purchase-link-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/present-external-purchase-link-ios.tsx @@ -94,7 +94,7 @@ struct ExternalPurchaseLinkResultIOS { Promise<ExternalPurchaseLinkResultIOS> {' '} — carries the result of opening the external link (success flag + any - error string from StoreKit). + error message).

    Example

    diff --git a/packages/docs/src/pages/docs/examples/android.tsx b/packages/docs/src/pages/docs/examples/android.tsx index 94202e186..42281e992 100644 --- a/packages/docs/src/pages/docs/examples/android.tsx +++ b/packages/docs/src/pages/docs/examples/android.tsx @@ -1,6 +1,4 @@ -import StoreExampleTemplate, { - type StoreExampleConfig, -} from '../../../components/StoreExampleTemplate'; +import type { StoreExampleConfig } from '../../../components/StoreExampleTemplate'; const ANDROID_VIDEO_BASE = '/examples/google/videos'; const ANDROID_POSTER = '/examples/google/home.webp'; @@ -333,9 +331,3 @@ adb shell monkey -p dev.hyo.martie -c android.intent.category.LAUNCHER 1`, }, }, }; - -function AndroidExample() { - return ; -} - -export default AndroidExample; diff --git a/packages/docs/src/pages/docs/examples/horizon.tsx b/packages/docs/src/pages/docs/examples/horizon.tsx index 7a715fe74..e01676aec 100644 --- a/packages/docs/src/pages/docs/examples/horizon.tsx +++ b/packages/docs/src/pages/docs/examples/horizon.tsx @@ -1,6 +1,4 @@ -import StoreExampleTemplate, { - type StoreExampleConfig, -} from '../../../components/StoreExampleTemplate'; +import type { StoreExampleConfig } from '../../../components/StoreExampleTemplate'; const HORIZON_VIDEO_BASE = '/examples/horizon/videos'; const HORIZON_POSTER = '/examples/horizon/home.webp'; @@ -343,9 +341,3 @@ adb shell monkey -p dev.hyo.martie -c android.intent.category.LAUNCHER 1`, }, }, }; - -function HorizonExample() { - return ; -} - -export default HorizonExample; diff --git a/packages/docs/src/pages/docs/examples/ios.tsx b/packages/docs/src/pages/docs/examples/ios.tsx index 5252958bb..e2812cfdf 100644 --- a/packages/docs/src/pages/docs/examples/ios.tsx +++ b/packages/docs/src/pages/docs/examples/ios.tsx @@ -1,6 +1,4 @@ -import StoreExampleTemplate, { - type StoreExampleConfig, -} from '../../../components/StoreExampleTemplate'; +import type { StoreExampleConfig } from '../../../components/StoreExampleTemplate'; const APPLE_ASSET_BASE = '/examples/apple'; const APPLE_VIDEO_BASE = `${APPLE_ASSET_BASE}/videos`; @@ -333,9 +331,3 @@ open Martie.xcodeproj`, }, }, }; - -function IosExample() { - return ; -} - -export default IosExample; diff --git a/packages/docs/src/pages/docs/features/discount.tsx b/packages/docs/src/pages/docs/features/discount.tsx index 82bc3b359..9928e59bb 100644 --- a/packages/docs/src/pages/docs/features/discount.tsx +++ b/packages/docs/src/pages/docs/features/discount.tsx @@ -169,7 +169,7 @@ data class DiscountOffer( class DiscountOffer { final String? id; - final String displayPrice; // "\$4.99" + final String displayPrice; // "$4.99" final double price; // 4.99 final String currency; // "USD" final DiscountOfferType type; diff --git a/packages/docs/src/pages/docs/features/external-purchase.tsx b/packages/docs/src/pages/docs/features/external-purchase.tsx index 7e4ebb64c..60b01dc8a 100644 --- a/packages/docs/src/pages/docs/features/external-purchase.tsx +++ b/packages/docs/src/pages/docs/features/external-purchase.tsx @@ -53,7 +53,7 @@ function ExternalPurchase() { iOS 17.4+ (Notice Sheet)
    - iOS 18.2+ (New APIs) + iOS 18.1+ (Custom Links) StoreKit 2 @@ -104,10 +104,10 @@ function ExternalPurchase() { returns results immediately - no browser redirect required.

    -

    Basic Usage (iOS 18.2+)

    +

    Basic Usage

    - iOS 18.2+ provides dedicated APIs for external purchase flow - with notice sheet and link presentation: + Check that the notice sheet can be shown, present it, then + open your purchase link when the user continues:

    {{ @@ -156,7 +156,7 @@ async function handleExternalPurchaseFlow() { swift: ( {`import OpenIAP -@available(iOS 18.2, *) +@available(iOS 17.4, *) func handleExternalPurchaseFlow() async { let externalUrl = "https://your-payment-site.com/checkout" @@ -199,7 +199,7 @@ func handleExternalPurchaseFlow() async { {`import dev.openiap.OpenIap import dev.openiap.ExternalPurchaseNoticeAction -// iOS 18.2+ External Purchase Flow (from Kotlin Multiplatform) +// iOS 17.4+ External Purchase Flow (from Kotlin Multiplatform) suspend fun handleExternalPurchaseFlow() { val externalUrl = "https://your-payment-site.com/checkout" @@ -239,7 +239,7 @@ suspend fun handleExternalPurchaseFlow() { {`import io.github.hyochan.kmpiap.KmpIAP import io.github.hyochan.kmpiap.ExternalPurchaseNoticeAction -// iOS 18.2+ External Purchase Flow (from Kotlin Multiplatform) +// iOS 17.4+ External Purchase Flow (from Kotlin Multiplatform) suspend fun handleExternalPurchaseFlow() { val kmpIAP = KmpIAP() val externalUrl = "https://your-payment-site.com/checkout" @@ -279,7 +279,7 @@ suspend fun handleExternalPurchaseFlow() { dart: ( {`import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; -// iOS 18.2+ External Purchase Flow +// iOS 17.4+ External Purchase Flow Future handleExternalPurchaseFlow() async { const externalUrl = 'https://your-payment-site.com/checkout'; @@ -339,7 +339,7 @@ async Task HandleExternalPurchaseFlowAsync() }`} ), gdscript: ( - {`# iOS 18.2+ External Purchase Flow + {`# iOS 17.4+ External Purchase Flow func handle_external_purchase_flow() -> void: var external_url = "https://your-payment-site.com/checkout" @@ -370,24 +370,16 @@ func handle_external_purchase_flow() -> void: }} - - The iOS 18.2+ API provides a cleaner flow with dedicated - methods for presenting the notice sheet and external purchase - link. This is the recommended approach for iOS 18.2 and later. - -

    Requirements

    • - iOS 17.4+ - Minimum version for External - Purchase API + iOS 17.4+ - Required for{' '} + canPresentExternalPurchaseNoticeIOS and{' '} + presentExternalPurchaseNoticeSheetIOS
    • - iOS 18.2+ - Recommended for dedicated - external purchase APIs ( - canPresentExternalPurchaseNoticeIOS,{' '} - presentExternalPurchaseNoticeSheetIOS,{' '} - presentExternalPurchaseLinkIOS) + iOS 18.1+ - Required for the{' '} + ExternalPurchaseCustomLink APIs
    • StoreKit 2 - Uses StoreKit 2 framework @@ -629,7 +621,8 @@ func handle_external_purchase_flow() -> void: FeatureNotSupported Error iOS version too old - Requires iOS 17.4+ (notice sheet), iOS 18.2+ (new APIs) + Requires iOS 17.4+ (notice sheet), iOS 18.1+ (custom + links) @@ -1286,7 +1279,7 @@ Future handleDeveloperBilling(DeveloperProvidedBillingDetailsAndroid detai print('External payment completed and reported!'); } } catch (e) { - print('External payment error: \$e'); + print('External payment error: $e'); } } @@ -1325,7 +1318,7 @@ Future handlePurchaseWithExternalPayments(String productId) async { // If user selects Google Play → purchaseUpdatedListener callback // If user selects developer billing → developerProvidedBillingAndroid callback } catch (e) { - print('Purchase error: \$e'); + print('Purchase error: $e'); } }`} ), @@ -1730,7 +1723,7 @@ func _ready_user_choice() -> void: {{ ios: ( <> -

      iOS Flow (iOS 18.2+)

      +

      iOS Flow (iOS 17.4+)

      @@ -1797,13 +1790,6 @@ func _ready_user_choice() -> void:
      - - - The iOS 18.2+ flow with dedicated APIs provides better user - experience with Apple's official notice sheet. The entire flow - happens within the app - no browser redirect or deep linking - required. - ), android: ( @@ -1904,21 +1890,21 @@ func _ready_user_choice() -> void:
      • AlternativeBillingScreen.swift {' '} - - Complete iOS 18.2+ implementation with notice sheet and - external purchase link presentation + - Complete implementation with notice sheet and external + purchase link presentation

      This example demonstrates:

      @@ -457,30 +290,17 @@ function OnePager() {
      diff --git a/packages/docs/src/pages/docs/foundation/research.tsx b/packages/docs/src/pages/docs/foundation/research.tsx index 4d314b479..f57b2dbf6 100644 --- a/packages/docs/src/pages/docs/foundation/research.tsx +++ b/packages/docs/src/pages/docs/foundation/research.tsx @@ -44,8 +44,8 @@ const STUDY_GROUPS: StudyGroup[] = [ 'Payment vulnerabilities trace back to payment SDK design, ambiguous documentation, and vulnerable sample code, which lead merchants into the mistakes that follow.', applied: ( <> - The reason OpenIAP exists as one audited specification with - consistent SDKs and a{' '} + The reason OpenIAP exists as one specification with consistent SDKs + and a{' '} conformance suite @@ -67,8 +67,8 @@ const STUDY_GROUPS: StudyGroup[] = [ applied: ( <> The differential mode of the conformance runner, which runs adapters - side by side and reports divergences. Ships as{' '} - openiap-conformance/differential with suite 3.0.0. + side by side and reports divergences. It is part of suite 3.0.0 in + the repository and is not published to npm. ), }, @@ -80,8 +80,8 @@ const STUDY_GROUPS: StudyGroup[] = [ applied: ( <> The versioned behavior registry: each behavior pins down semantics - the GraphQL schema alone cannot, so six SDKs cannot drift apart - silently. + the GraphQL schema alone cannot, so the SDKs bound to it cannot + drift apart silently. ), }, @@ -94,8 +94,8 @@ const STUDY_GROUPS: StudyGroup[] = [ <> The metamorphic relation registry used to verify live store behavior — for example, a purchased item must appear in a following restore. - Ships as openiap-conformance/metamorphic with suite - 3.0.0. + It is part of suite 3.0.0 in the repository and is not published to + npm. ), }, @@ -125,8 +125,8 @@ const STUDY_GROUPS: StudyGroup[] = [ '20.1% of non-major upgrades in Maven Central contain breaking changes.', applied: ( <> - The same guard, plus the version floor policy in{' '} - openiap-versions.json that release audits enforce. + The same guard, plus the Client Protocol version check that release + workflows run before publishing. ), }, @@ -155,10 +155,9 @@ const STUDY_GROUPS: StudyGroup[] = [ 'Documentation is the dominant obstacle to learning an API. The 2009 article surveys and interviews developers; the 2011 field study, across more than 440 professional developers, is the source of the documentation factors.', applied: ( <> - The reader-first standard every OpenIAP doc follows, and the - issue-mining pipeline that collects nine years of failure reports - across the six SDK ecosystems as the evidence base for - troubleshooting docs. + The reader-first standard every OpenIAP doc follows. A study of nine + years of failure reports across the six SDK ecosystems has mined its + corpus but not classified any issue yet. ), }, diff --git a/packages/docs/src/pages/docs/foundation/roadmap-budget.tsx b/packages/docs/src/pages/docs/foundation/roadmap-budget.tsx index 76e61477d..4bf9a5414 100644 --- a/packages/docs/src/pages/docs/foundation/roadmap-budget.tsx +++ b/packages/docs/src/pages/docs/foundation/roadmap-budget.tsx @@ -1,8 +1,163 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; import SEO from '../../../components/SEO'; import AnchorLink from '../../../components/AnchorLink'; import Callout from '../../../components/Callout'; +import DataTable from '../../../components/DataTable'; import { useScrollToHash } from '../../../hooks/useScrollToHash'; +type Status = 'Done' | 'In progress' | 'Not started'; + +interface Deliverable { + name: string; + description: ReactNode; + status: Status; + /** Where the shipped part lives, or what is still missing. */ + note?: ReactNode; +} + +interface Phase { + id: string; + title: string; + deliverables: Deliverable[]; +} + +const ROADMAP: Phase[] = [ + { + id: 'phase-1', + title: 'Phase 1: Foundation (Q2–Q3 2026)', + deliverables: [ + { + name: 'Open governance model', + description: + 'Published governance document with maintainer policies and decision-making process', + status: 'Done', + note: ( + <> + Governance, still + marked draft + + ), + }, + { + name: 'Specification documentation', + description: 'Normative documentation for both protocols', + status: 'In progress', + note: 'Commerce Protocol done; the Client Protocol has its schema and API reference but no normative prose yet', + }, + { + name: 'Purchase verification profile', + description: 'Standardized server-side verification across stores', + status: 'Done', + note: ( + <> + The Commerce Protocol{' '} + verification profile + + ), + }, + { + name: 'Conformance test suite', + description: + 'Shared behavioral expectations executed against every store implementation and verification provider, backed by a machine-checked capability matrix', + status: 'In progress', + note: 'Expo, React Native, Android, Apple and IAPKit cover documented subsets; Flutter, KMP, MAUI and Godot adapters are next', + }, + { + name: 'Founding supporter outreach', + description: 'Engage 3–5 organizations as initial supporters', + status: 'In progress', + }, + ], + }, + { + id: 'phase-2', + title: 'Phase 2: Ecosystem Growth (Q4 2026–Q1 2027)', + deliverables: [ + { + name: 'Security guidance document', + description: + 'Transaction integrity best practices, fraud prevention patterns, audit-friendly purchase schema', + status: 'In progress', + note: ( + <> + Receipt validation{' '} + guidance exists; integrity and fraud guidance does not + + ), + }, + { + name: 'Expanded platform support', + description: + 'Unity and Unreal Engine codegen plugins via the IR architecture', + status: 'Not started', + }, + { + name: 'Secure provider interoperability spec', + description: + 'Standardized handoff protocol between stores, apps, and verification services', + status: 'Done', + note: Commerce Protocol, + }, + { + name: 'Formal spec versioning', + description: + 'Semantic versioning for the specification with migration guides per platform', + status: 'Done', + note: ( + <> + Both protocols are versioned; see{' '} + Versions + + ), + }, + { + name: 'Open funding channel', + description: + 'Transparent funding channel for individual and corporate donors', + status: 'Done', + note: Sponsors, + }, + ], + }, + { + id: 'phase-3', + title: 'Phase 3: Industry Standard (Q2–Q4 2027)', + deliverables: [ + { + name: 'Conformance certification', + description: + 'Formal certification process for libraries claiming OpenIAP compatibility', + status: 'Not started', + }, + { + name: 'Third-party auditor guidelines', + description: 'Guidance for auditors reviewing verification providers', + status: 'Not started', + note: 'The provider integration spec itself shipped as the Commerce Protocol', + }, + { + name: 'Alternative store support', + description: 'EU DMA compliance, alternative app store billing APIs', + status: 'In progress', + note: 'Amazon Appstore, Meta Horizon, Onside and external purchase links are supported; HarmonyOS, Galaxy Store and AppGallery are not', + }, + { + name: 'Foundation hosting exploration', + description: + 'Evaluate foundation hosting options for long-term neutral governance', + status: 'Not started', + }, + { + name: 'Mentorship program', + description: + 'Structured onboarding themes for new contributors (bindings, tests, docs)', + status: 'Not started', + }, + ], + }, +]; + function RoadmapBudget() { useScrollToHash(); @@ -20,8 +175,8 @@ function RoadmapBudget() { as the governance structure is finalized.

      - This document outlines how OpenIAP plans to grow and how sponsorship - funding is allocated. Full transparency on where every dollar goes. + How OpenIAP plans to grow, what has shipped, and how sponsorship funding + is planned to be spent.

      @@ -29,157 +184,37 @@ function RoadmapBudget() { Development Roadmap - - Phase 1: Foundation (Q2–Q3 2026) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      DeliverableDescriptionStatus
      Open governance model - Published governance document with maintainer policies and - decision-making process - Done
      Specification documentation - Formal documentation of the GraphQL schema as the cross-platform - purchase specification - In Progress
      Purchase verification profile - Standardized server-side receipt validation patterns for iOS and - Android - Planned
      Conformance test suite v1 - Shared behavioral expectations executed against every store - implementation and verification provider, backed by a - machine-checked capability matrix. Android stores, Apple, IAPKit - providers, React Native IAP, and Expo IAP are covered; Flutter, - KMP, MAUI, and Godot adapters are next. - In Progress
      Founding supporter outreachEngage 3–5 organizations as initial supportersIn Progress
      - - - Phase 2: Ecosystem Growth (Q4 2026–Q1 2027) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      DeliverableDescription
      Security guidance document - Transaction integrity best practices, fraud prevention patterns, - audit-friendly purchase schema -
      Expanded platform support - Unity and Unreal Engine codegen plugins via the IR architecture -
      Secure provider interoperability spec - Standardized handoff protocol between stores, apps, and - verification services -
      Formal spec versioning - Semantic versioning for the specification with migration guides - per platform -
      Open funding channel - Transparent funding channel for individual and corporate donors -
      - - - Phase 3: Industry Standard (Q2–Q4 2027) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      DeliverableDescription
      Conformance certification - Formal certification process for libraries claiming OpenIAP - compatibility -
      Third-party auditor guidelinesIntegration specs for verification service providers
      Alternative store supportEU DMA compliance, alternative app store billing APIs
      Foundation hosting exploration - Evaluate foundation hosting options for long-term neutral - governance -
      Mentorship program - Structured onboarding themes for new contributors (bindings, - tests, docs) -
      + {ROADMAP.map((phase) => ( +
      + + {phase.title} + + row.name} + columns={[ + { header: 'Deliverable', cell: (row) => row.name }, + { header: 'Description', cell: (row) => row.description }, + { + header: 'Status', + cell: (row) => ( + <> + {row.status} + {row.note ? <> — {row.note} : null} + + ), + }, + ]} + /> +
      + ))}
      Budget Allocation -

      How sponsorship funds are allocated across project needs:

      +

      The planned split of sponsorship funds:

      @@ -289,8 +324,7 @@ function RoadmapBudget() { > kit.openiap.dev {' '} - receipt validation — community service funded through - OpenCollective + receipt validation diff --git a/packages/docs/src/pages/docs/foundation/sponsorship.tsx b/packages/docs/src/pages/docs/foundation/sponsorship.tsx index 1822c77f1..edc41649f 100644 --- a/packages/docs/src/pages/docs/foundation/sponsorship.tsx +++ b/packages/docs/src/pages/docs/foundation/sponsorship.tsx @@ -1,10 +1,17 @@ import SEO from '../../../components/SEO'; import AnchorLink from '../../../components/AnchorLink'; import Callout from '../../../components/Callout'; +import DataTable from '../../../components/DataTable'; import { useScrollToHash } from '../../../hooks/useScrollToHash'; -import { CURRENT_SPONSORS, FUNDING_LINKS } from '../../../lib/sponsors'; +import { + CURRENT_SPONSORS, + FUNDING_LINKS, + SPONSOR_TIERS, +} from '../../../lib/sponsors'; import { Link } from 'react-router-dom'; +const formatUsd = (usd: number): string => `$${usd.toLocaleString('en-US')}`; + function Sponsorship() { useScrollToHash(); @@ -48,8 +55,8 @@ function Sponsorship() { Reduced bus factor @@ -73,11 +80,12 @@ function Sponsorship() { @@ -124,154 +132,34 @@ function Sponsorship() { Sponsorship Tiers - -
      -
      -

      - Bronze -

      -

      - $100/month -

      -
        -
      • Logo on project README
      • -
      • Listed on sponsors page
      • -
      • Community supporter badge
      • -
      -
      - -
      -

      - Silver -

      -

      - $300/month -

      -
        -
      • Everything in Bronze
      • -
      • Logo featured in README with link
      • -
      • Quarterly progress report
      • -
      -
      - -
      -

      - Gold -

      -

      - $500/month -

      -
        -
      • Everything in Silver
      • -
      • Large logo across all repositories
      • -
      • Priority issue triage
      • -
      • Monthly maintainer sync call
      • -
      -
      - - -
      + GitHub Sponsors + + : +

      + row.name} + columns={[ + { header: 'Tier', cell: (row) => {row.name} }, + { header: 'Monthly', cell: (row) => formatUsd(row.usd) }, + { header: 'Includes', cell: (row) => row.includes }, + ]} + /> +

      + One-time support:{' '} + {SPONSOR_TIERS.oneTime + .map((tier) => `${tier.name} (${formatUsd(tier.usd)})`) + .join(', ')} + . Sponsorship buys recognition and priority, not a service-level + agreement or reserved engineering capacity. +

      @@ -279,123 +167,38 @@ function Sponsorship() { Sponsorship Channels

      - OpenIAP keeps project money and personal maintainer support on - separate, clearly labeled rails: + Sponsor through{' '} + + GitHub Sponsors + {' '} + or{' '} + + OpenCollective + + , which offers the same monthly tiers with a public ledger. The{' '} + Sponsors page lists every channel.

      -
      - Multiple maintainers and a governance structure ensure the - project doesn't depend on one person + The project has one maintainer today; sponsorship funds the + maintainer time and governance meant to reduce that risk
      - Security verification profiles + Verification profile - Industry-standard receipt validation and fraud prevention - patterns you don't have to build yourself + A standard server-side purchase verification contract, the + Commerce Protocol, that your backend or provider can implement + instead of building its own
      - - - - - - - - - - - - - - - - - - - -
      ChannelUse It ForWhere the Money Goes
      - - OpenCollective - - - Corporate sponsorship tiers, project funding, IAPKit community - instance infrastructure - - The project fund, with a public ledger — expenses (hosting, - maintainer compensation per the{' '} - - budget allocation - - ) are transparent -
      - - GitHub Sponsors - - Personal appreciation for the maintainer's work - The maintainer directly — disclosed here for transparency, and - separate from project accounting -
      - What Your Funding Supports + What Funding Supports - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      AreaActivities
      - Specification Development - - GraphQL schema evolution, IR codegen plugins, new platform - bindings -
      - Security - - Verification profiles, receipt validation patterns, audit-ready - schemas -
      - Testing Infrastructure - - Conformance tests, CI/CD pipelines, cross-platform test matrix -
      - Documentation - - API docs, migration guides, tutorials, AI assistant context -
      - Community - - Contributor onboarding, mentorship programs, conference presence -
      - Operations - Hosting, domain, CI compute, release management
      +

      + The planned split of sponsorship funds is on{' '} + + Roadmap & Budget + + . +

      @@ -403,35 +206,11 @@ function Sponsorship() { The Security Value

      - OpenIAP is more than developer experience — it's purchase - infrastructure with a security layer: -

      -
        -
      • - Purchase verification profiles — standardized - server-side validation -
      • -
      • - Receipt validation best practices — cross-platform - guidance to prevent manipulation -
      • -
      • - Transaction integrity — audit-friendly schemas with - structured logging -
      • -
      • - Secure provider interoperability — safe handoffs - between stores and apps -
      • -
      • - Fraud reduction — shared patterns to detect and - prevent purchase fraud -
      • -
      -

      - By building security into the standard itself, every library in the - OpenIAP ecosystem inherits these protections — reducing risk for the - entire community. + Purchase verification is standardized in the Commerce Protocol{' '} + verification profile, + with validation guidance{' '} + for each store. Integrity and fraud guidance is on the{' '} + roadmap.

      diff --git a/packages/docs/src/pages/docs/foundation/whitepapers.tsx b/packages/docs/src/pages/docs/foundation/whitepapers.tsx index d71e212b0..d15705cf6 100644 --- a/packages/docs/src/pages/docs/foundation/whitepapers.tsx +++ b/packages/docs/src/pages/docs/foundation/whitepapers.tsx @@ -21,8 +21,15 @@ function Whitepapers() { Longer-form documents that explain the reasoning behind OpenIAP’s design, published as PDFs so they can be read and cited outside the repository. Each states its own scope and what it does not establish. - The specification remains - authoritative for normative wording. + The{' '} + + specification + {' '} + remains authoritative for normative wording.

      {WHITEPAPERS.map((paper) => ( diff --git a/packages/docs/src/pages/docs/getting-started.tsx b/packages/docs/src/pages/docs/getting-started.tsx index e7fa7a3b1..fb7b9c3ad 100644 --- a/packages/docs/src/pages/docs/getting-started.tsx +++ b/packages/docs/src/pages/docs/getting-started.tsx @@ -181,7 +181,7 @@ let purchaseSubscription = store.purchaseUpdatedListener { purchase in isConsumable: false ) } catch { - print("Failed to finish transaction: \(error)") + print("Failed to finish transaction: \\(error)") } } } diff --git a/packages/docs/src/pages/docs/guides/ai-assistants.tsx b/packages/docs/src/pages/docs/guides/ai-assistants.tsx index 8933070c4..70e35aa6e 100644 --- a/packages/docs/src/pages/docs/guides/ai-assistants.tsx +++ b/packages/docs/src/pages/docs/guides/ai-assistants.tsx @@ -686,7 +686,7 @@ npm run demo:experience`} - navigate(`#build-step-${step}`, { + void navigate(`#build-step-${step}`, { state: { commerceKeepScroll: true }, }) } diff --git a/packages/docs/src/pages/docs/setup/expo.tsx b/packages/docs/src/pages/docs/setup/expo.tsx index db1ed0b18..ebb96b035 100644 --- a/packages/docs/src/pages/docs/setup/expo.tsx +++ b/packages/docs/src/pages/docs/setup/expo.tsx @@ -280,8 +280,11 @@ cd ios && pod install`} Onside (an iOS alternative marketplace), Horizon OS{' '} (Meta Quest), and Amazon{' '} - (Fire OS devices and the Vega OS runtime). All modules are off by - default; enable only the stores you ship to. + (Fire OS devices and the Vega OS runtime). Android store selection + happens when Gradle runs — a store flavor, a connected Quest or Fire + device on a local debug build, or an openiapStore pin — + so keep the store credentials in the config; a build that must target + one store is pinned in its EAS profile, not in the config.

      {`{ @@ -293,15 +296,16 @@ cd ios && pod install`} "iapkitApiKey": "openiap-kit_pk_", "modules": { "onside": true, - "horizon": true, "amazon": { - "fireOS": false, "vegaOS": false } }, "android": { "horizon": { "appId": "YOUR_HORIZON_APP_ID" + }, + "amazon": { + "appstoreKey": "./AppstoreAuthenticationKey.pem" } } } @@ -316,13 +320,21 @@ cd ios && pod install`} rules — live in each store's setup page linked above.

      - Module enable flags live under modules; platform-specific - values live under android or ios. For - Amazon, modules.amazon.fireOS and{' '} - modules.amazon.vegaOS toggle each target; the separate{' '} - android.amazon.vegaOS block is only needed when your Vega - OS build requires different values (app id, artifacts) than your - regular Android config — see{' '} + Platform-specific values live under android or{' '} + ios; modules holds opt-ins.{' '} + modules.onside links the Onside SDK and{' '} + modules.amazon.vegaOS generates the Vega target. The + Android store needs no option: a local debug build follows the + connected Quest or Fire device. An EAS cloud build has no device to + follow and a release build never looks at one, so pin them with{' '} + ORG_GRADLE_PROJECT_openiapStore in the EAS profile's{' '} + env (see{' '} + + How the Store Is Selected + + ). The separate android.amazon.vegaOS block is only + needed when your Vega OS build requires different values (app id, + artifacts) than your regular Android config — see{' '} Amazon Store Setup.

      diff --git a/packages/docs/src/pages/docs/setup/flutter.tsx b/packages/docs/src/pages/docs/setup/flutter.tsx index 955d2120b..43de78bb7 100644 --- a/packages/docs/src/pages/docs/setup/flutter.tsx +++ b/packages/docs/src/pages/docs/setup/flutter.tsx @@ -217,22 +217,23 @@ function FlutterSetup() { If the app uses this package only on iOS or macOS, add the following to android/gradle.properties:

      - {`openiapPlatform=none`} + {`openiapStore=none`}

      Run flutter clean before rebuilding. This keeps the Android plugin registered with a no-op implementation while excluding OpenIAP Google, Play Billing, Horizon, and Amazon IAP SDK dependencies and the billing manifest entries supplied by them.{' '} initConnection() returns false; Android - store operations report ErrorCode.IapNotAvailable. Omit - the property to keep Google Play as the default. + store operations report ErrorCode.IapNotAvailable. + Without the property the build resolves the store itself (see{' '} + Store Setup); the legacy{' '} + openiapPlatform=none spelling still works with a + deprecation warning. Do not pin a store while the opt-out is set, or + keep a legacy horizonEnabled/fireOsEnabled{' '} + flag alongside a pin — the build fails rather than guess which one you + meant. openiapStore=auto is the exception: it means + "no pin", so the opt-out beside it still applies.

      - - openiapPlatform=none cannot be combined with{' '} - horizonEnabled or fireOsEnabled. Disable - both legacy store flags first, or the Android build fails with{' '} - openiapPlatform=none conflicts with legacy store flags. -

      ProGuard Rules (if using ProGuard)

      diff --git a/packages/docs/src/pages/docs/setup/godot.tsx b/packages/docs/src/pages/docs/setup/godot.tsx index 63579ec12..de4b8b101 100644 --- a/packages/docs/src/pages/docs/setup/godot.tsx +++ b/packages/docs/src/pages/docs/setup/godot.tsx @@ -838,11 +838,10 @@ func _on_purchase_error(error): multi-language examples

    • - Store Setup — support boundaries for - alternative stores such as{' '} - Horizon OS (Meta Quest) and{' '} - Fire OS / Vega OS (Amazon); - godot-iap does not yet ship dedicated flavors for these targets + Store Setup — ship to{' '} + Horizon OS (Meta Quest) or{' '} + Fire OS (Amazon) with the{' '} + openiap/android_store export option
    • name === 'kmp-iap')?.installCommand ?? @@ -205,6 +205,49 @@ kotlin { }`} +

      Pick the Android store

      +

      + kmp-iap publishes a Play, Horizon, and Amazon build of its Android + library, and Gradle stops with{' '} + + Cannot choose between the following variants of + io.github.hyochan:kmp-iap + {' '} + until the build names one. Apply the OpenIAP Gradle plugin once in{' '} + settings.gradle.kts; it reaches every Android module of + that build, including an app module that sees kmp-iap only through a + shared module. A build pulled in with includeBuild{' '} + applies it in its own settings. It links Play by default, the + connected Quest or Fire device's store on a debug build, and the + store openiapStore pins, which is how release builds + choose. +

      + + {`// settings.gradle.kts — keep mavenCentral() in pluginManagement.repositories +plugins { + id("io.github.hyochan.openiap") version "${OPENIAP_VERSIONS.google}" +}`} + + + {`# gradle.properties — only for a build that must not follow the device +openiapStore=horizon`} + +

      + Without the plugin, name the store in every Android module:{' '} + missingDimensionStrategy("platform", "play") in the{' '} + defaultConfig of application and library modules, or{' '} + + localDependencySelection {'{'} productFlavorDimension("platform"){' '} + {'{'} selectFrom.set(listOf("play")) {'}'} {'}'} + {' '} + inside the Kotlin Multiplatform Android library block (AGP 8.12 or + later; AGP 9 removed the older dependencyVariantSelection + ). A module that declares its own platform flavors keeps + choosing per flavor. See{' '} + How the Store Is Selected{' '} + for the full rule. +

      +

      ProGuard Rules (if using ProGuard)

      {`# In-App Purchase diff --git a/packages/docs/src/pages/docs/setup/maui.tsx b/packages/docs/src/pages/docs/setup/maui.tsx index 988c59324..5ad20bf1f 100644 --- a/packages/docs/src/pages/docs/setup/maui.tsx +++ b/packages/docs/src/pages/docs/setup/maui.tsx @@ -1,3 +1,4 @@ +import { Link } from 'react-router-dom'; import Callout from '../../../components/Callout'; import CodeBlock from '../../../components/CodeBlock'; import SEO from '../../../components/SEO'; @@ -111,20 +112,34 @@ function MauiSetup() {

      Your app references a single package, OpenIap.Maui. The OpenIAP-owned iOS and Android bindings are bundled inside it, while - shared dependencies (Google Play Billing, Play Services, AndroidX, - Kotlin, Gson) remain ordinary NuGet dependencies so NuGet can - deduplicate them with the rest of your dependency graph. + shared dependencies (Play Services, AndroidX, Kotlin, Gson) remain + ordinary NuGet dependencies so NuGet can deduplicate them with the + rest of your dependency graph. The store SDK itself (Google Play + Billing, the Horizon billing library, or the Amazon Appstore SDK) is + linked when the app builds; see{' '} + Android Store.

      If you are working from this monorepo before publishing, use a project - reference to the main project only. The example app re-declares local - native references because MSBuild does not propagate those - transitively through ProjectReference. Published NuGet - consumers do not need that. + reference to the main project only. MSBuild does not propagate native + references or the package's build files through{' '} + ProjectReference, so the example app re-declares its + native references and imports the Android store selection, pointing it + at the store AARs built in packages/google. Published + NuGet consumers do not need either.

      - {``} + {` + + + path/to/openiap/packages/google/openiap/build/outputs/aar/ + + + + + +`}

      Building the Apple library from source requires Xcode 27; the @@ -139,6 +154,46 @@ function MauiSetup() { +

      +

      + Android Store + + # + +

      +

      + A Debug build links the store of the device it deploys to — the + IDE's target (AdbTarget), else the one{' '} + ANDROID_SERIAL names, else the only one attached: a Quest + gets Meta Horizon, a Fire device the Amazon Appstore, anything else + Google Play. Only the Debug configuration looks at a + device; Release and any other configuration link Google Play, so pin + every build that ships to another store with OpenIapStore + . The build logs its choice as{' '} + openiap: store=horizon (source=device; ...). +

      + + {`dotnet publish -f net10.0-android -c Release -p:OpenIapStore=horizon`} + + + NuGet fixes a package's dependencies before the build knows the + store, so every MAUI Android build carries the NuGet libraries any + store needs: Play Services and DataTransport (about 3.1 MB, used by + Google Play Billing) and kotlinx-serialization-json (up to 0.9 MB, + used by Horizon). Play Services also merges its manifest entries into + Quest and Fire builds: the ACCESS_NETWORK_STATE{' '} + permission, DataTransport's services and receiver,{' '} + GoogleApiActivity, and the{' '} + com.google.android.gms.version meta-data, all idle there. + The store SDK itself is linked for the chosen store only. React + Native, Expo, Flutter, Godot, KMP, and native Android builds link only + the chosen store's dependencies. + +
      +

      Project Configuration @@ -190,6 +245,58 @@ function MauiSetup() {

    + +

    + iOS 27 terminates an app built with that SDK unless it adopts the + UIScene lifecycle, before OpenIAP or StoreKit can run. Apple states + the same requirement for Mac Catalyst 27. See the{' '} + + Xcode 27 UIScene checklist + + . MAUI supplies the delegate, but the linker keeps it only when a + registered subclass names it, so add one per Apple platform folder: +

    + + {`// Platforms/iOS/SceneDelegate.cs (mirror in Platforms/MacCatalyst) +using Foundation; +using Microsoft.Maui; + +[Register("SceneDelegate")] +public class SceneDelegate : MauiUISceneDelegate +{ +}`} + +

    + Then point Info.plist at it in both folders: +

    + + {`UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + __MAUI_DEFAULT_SCENE_CONFIGURATION__ + UISceneDelegateClassName + SceneDelegate + + + +`} + +

    + An empty UISceneConfigurations stops the crash but + leaves a black screen, and an incremental dotnet build{' '} + reuses the old Info.plist — delete{' '} + bin/ and obj/ for that target framework + after editing it. +

    +
    +

    Android @@ -453,13 +560,13 @@ if (!ended) Console.WriteLine("Store teardown did not complete");`} Billing.

    - The example app builds against the in-repo Android library, so rebuild - the OpenIAP Android AARs once before the first run (published-package - consumers skip this step): + The example app builds against the in-repo Android library, so build + every store's OpenIAP Android AAR once before the first run + (published-package consumers skip this step):

    {`# From the OpenIAP repo root: -(cd packages/google && ./gradlew :openiap:assemblePlayRelease) +(cd packages/google && ./gradlew :openiap:assemblePlayRelease :openiap:assembleHorizonRelease :openiap:assembleAmazonRelease) (cd libraries/maui-iap/android && ../../../packages/google/gradlew :openiap:assembleRelease)`}

    @@ -484,9 +591,9 @@ dotnet build -t:Run -f net10.0-maccatalyst`} VS Code launch configurations are available in{' '} libraries/maui-iap/.vscode/launch.json. The iOS device launcher auto-selects a connected USB device when one is available, - and the Android launcher builds both Android AARs before uninstalling - and rebuilding the example app so stale APKs do not keep old - BillingClient code. + and the Android launcher builds the Android AARs and passes its device + to the build, so the example links that device's store, before + uninstalling and rebuilding the example app.

    diff --git a/packages/docs/src/pages/docs/setup/store/amazon.tsx b/packages/docs/src/pages/docs/setup/store/amazon.tsx index 4255d80e5..09d0ed7b0 100644 --- a/packages/docs/src/pages/docs/setup/store/amazon.tsx +++ b/packages/docs/src/pages/docs/setup/store/amazon.tsx @@ -176,16 +176,17 @@ function AmazonStoreSetup() { Native Android - Use openiap-google-amazon or select{' '} - platform=amazon. + The OpenIAP Gradle plugin: a connected Fire device on a debug + build; openiapStore=amazon pins it. Not a Kepler target. Expo - modules.amazon.fireOS in the expo-iap{' '} - config plugin. + Resolved at build time; an EAS profile pins a release with{' '} + ORG_GRADLE_PROJECT_openiapStore=amazon.{' '} + android.amazon.appstoreKey supplies the public key. modules.amazon.vegaOS, with optional{' '} @@ -195,7 +196,8 @@ function AmazonStoreSetup() { React Native - fireOsEnabled=true in Gradle properties. + Resolved at build time by the shared Gradle resolver;{' '} + openiapStore=amazon pins it. Separate React Native for Vega target with Kepler dependencies @@ -205,31 +207,30 @@ function AmazonStoreSetup() { Flutter - fireOsEnabled=true and app-level{' '} - missingDimensionStrategy. + Resolved at build time by the shared Gradle resolver;{' '} + openiapStore=amazon pins it. No Vega runtime target. KMP - - Build/publish the Android amazonRelease variant. - + The OpenIAP Gradle plugin, as for native Android. No Vega runtime target. MAUI - Build Android with OpenIapAndroidStore=amazon,{' '} - fire, fireos, or fire-os. + A connected Fire device on a Debug build;{' '} + OpenIapStore=amazon pins it. No Vega runtime target. Godot - Shared Amazon store and verification types exist; no dedicated - Fire OS flavor switch yet. + Left at auto, a debug export follows a connected + Fire device; for release, set openiap/android_store{' '} + to amazon. No Vega runtime target. @@ -243,40 +244,58 @@ function AmazonStoreSetup() {

    Fire OS artifacts link the Amazon Appstore SDK and resolve purchases - through the Android amazon flavor. + through the Android amazon flavor. Every Fire OS build + also needs the Amazon public key: download{' '} + AppstoreAuthenticationKey.pem for the app from the Amazon + Developer Console and ship it in{' '} + android/app/src/main/assets. The SDK verifies receipts + with it, and it is inert on every other store, so it stays in the + project permanently.

    Native Android

    - Depend on the Amazon artifact and select the amazon{' '} - flavor in the app's Gradle build: + Depend on openiap-google and apply the OpenIAP Gradle + plugin; it links openiap-google-amazon instead when a + debug build finds a Fire device, or when{' '} + openiapStore=amazon pins a release.

    - {`dependencies { - implementation("io.github.hyochan.openiap:openiap-google-amazon:${OPENIAP_VERSIONS.google}") + {`// settings.gradle.kts +plugins { + id("io.github.hyochan.openiap") version "${OPENIAP_VERSIONS.google}" } -android { - defaultConfig { - missingDimensionStrategy("platform", "amazon") - } +// app/build.gradle.kts +dependencies { + implementation("io.github.hyochan.openiap:openiap-google:${OPENIAP_VERSIONS.google}") }`} Expo

    - Expo apps use the config plugin. The plugin writes Gradle selection, - dependency injection, and Fire OS manifest cleanup during prebuild. + Expo apps keep the Amazon public key in the config plugin; the plugin + copies it into android/app/src/main/assets on every + prebuild and the Gradle build picks the store. An EAS build has no + Fire device to follow and a release build never looks, so pin every + EAS profile that must target Fire OS in its env.

    + {`{ + "build": { + "fire": { + "env": { "ORG_GRADLE_PROJECT_openiapStore": "amazon" } + } + } +}`} {`plugins: [ [ 'expo-iap', { - modules: { + android: { amazon: { - fireOS: true, + appstoreKey: './AppstoreAuthenticationKey.pem', }, }, }, @@ -287,25 +306,19 @@ android { React Native

    - Bare React Native selects Fire OS at the Gradle layer; there is no RN - config plugin for Amazon options. The same switch also drives Horizon - OS (Meta Quest) builds — see{' '} - Horizon OS Setup — so keep{' '} - horizonEnabled=false in Amazon artifacts. + Bare React Native applies the same resolver script the library uses. + Place AppstoreAuthenticationKey.pem in{' '} + android/app/src/main/assets; an amazon{' '} + flavor, a connected Fire device on a debug build, or{' '} + -PopeniapStore=amazon then picks the store, exactly as + for Horizon OS.

    - {`# android/gradle.properties -fireOsEnabled=true -horizonEnabled=false`} {`// android/app/build.gradle +apply from: new File(project(':react-native-iap').projectDir, 'openiap-store.gradle') + android { defaultConfig { - def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false - def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false - if (horizonEnabled && fireOsEnabled) { - throw new GradleException("horizonEnabled and fireOsEnabled cannot both be true") - } - def flavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') - missingDimensionStrategy "platform", flavor + missingDimensionStrategy "platform", openIapResolveStore('app').store } }`} @@ -313,20 +326,17 @@ android { Flutter

    - Flutter uses the same Gradle property model as bare React Native — set - the property, then map it to the plugin flavor in the app module: + Flutter applies the resolver from the plugin project. Keep the key in{' '} + android/app/src/main/assets; pin a release build with{' '} + ORG_GRADLE_PROJECT_openiapStore=amazon flutter build apk.

    - {`# android/gradle.properties -fireOsEnabled=true -horizonEnabled=false`} {`// android/app/build.gradle -def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false -def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false -def flavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') +apply from: new File(project(':flutter_inapp_purchase').projectDir, 'openiap-store.gradle') +def openIapStore = openIapResolveStore('app', [allowNone: true]).store android { defaultConfig { - missingDimensionStrategy 'platform', flavor + missingDimensionStrategy 'platform', openIapStore == 'none' ? 'play' : openIapStore } }`} @@ -334,12 +344,23 @@ android { KMP and MAUI

    - KMP exposes the Android amazonRelease variant and sets{' '} - OPENIAP_STORE="amazon". MAUI selects the Amazon AAR - flavor by MSBuild property. + KMP apps apply the same plugin in settings.gradle.kts, + which links kmp-iap's Amazon build. A MAUI Debug build follows a + connected Fire device; pin a release with the MSBuild property. Every + MAUI build also carries the libraries the other stores need ( + MAUI Setup). The + public key goes in the Android app's assets: a KMP app's{' '} + src/androidMain/assets, or a MAUI app's{' '} + Platforms/Android/Assets. +

    + {`dotnet publish -f net10.0-android -c Release -p:OpenIapStore=amazon`} +

    + A Godot export picks Amazon from a connected Fire device on a debug + export, or from openiap/android_store=amazon. Put{' '} + AppstoreAuthenticationKey.pem at the project root and add + it to the Android preset's filter for non-resource files; the + export packs it into the APK's assets, where the SDK reads it.

    - {`./gradlew :library:assembleAmazonRelease -dotnet build -f net10.0-android -p:OpenIapAndroidStore=amazon`} install Amazon Kepler packages.

    - fireOsEnabled selects the Fire OS Android flavor only — - it is not a Vega selector and has no effect on Vega builds. + The Android store resolver (openiapStore, flavors, + connected devices) picks the Fire OS Android flavor only — it is not a + Vega selector and has no effect on Vega builds.

    Check the{' '} @@ -397,9 +419,9 @@ dotnet build -f net10.0-android -p:OpenIapAndroidStore=amazon`} Expo

    - Expo can prepare the Vega target from config. fireOS and{' '} - vegaOS can both be enabled, but they still produce - separate artifacts; keep both flags in modules.amazon. + Expo prepares the Vega target from modules.amazon.vegaOS. + The Fire OS build is a separate artifact that the Android build picks + like any other store.

    {`plugins: [ [ @@ -407,7 +429,6 @@ dotnet build -f net10.0-android -p:OpenIapAndroidStore=amazon`} { modules: { amazon: { - fireOS: true, vegaOS: true, }, }, diff --git a/packages/docs/src/pages/docs/setup/store/horizon.tsx b/packages/docs/src/pages/docs/setup/store/horizon.tsx index 24a2c5113..a31ce22ae 100644 --- a/packages/docs/src/pages/docs/setup/store/horizon.tsx +++ b/packages/docs/src/pages/docs/setup/store/horizon.tsx @@ -53,8 +53,9 @@ function HorizonStoreSetup() { Expo uses android.horizon.appId. Bare React Native reads a Gradle property named horizonAppId; Flutter reads HORIZON_APP_ID from{' '} - android/local.properties. Both write Android - manifest meta-data{' '} + android/local.properties; Godot reads the{' '} + openiap/horizon_app_id export option. Each writes + Android manifest meta-data{' '} com.meta.horizon.platform.HORIZON_APP_ID. @@ -122,10 +123,10 @@ function HorizonStoreSetup() { Framework Setup

    - Every framework except Godot ships Quest support through the same - Android horizon flavor; only the switch location differs. - Find your framework here, then follow the matching section below for - full snippets. + Every framework ships Quest support through the same Horizon build of{' '} + openiap-google; only the switch location differs. Find + your framework here, then follow the matching section below for full + snippets.

    @@ -139,32 +140,35 @@ function HorizonStoreSetup() { + - - + + -
    Native Android - Depend on openiap-google-horizon or select{' '} - platform=horizon. + The OpenIAP Gradle plugin: a connected Quest on a debug build;{' '} + openiapStore=horizon pins it. Android manifest meta-data.
    Expo - modules.horizon plus{' '} - android.horizon.appId in the expo-iap{' '} - config plugin. + Resolved at build time; an EAS profile pins a release with{' '} + ORG_GRADLE_PROJECT_openiapStore=horizon. + + android.horizon.appId; the config plugin writes + manifest meta-data. The config plugin writes manifest meta-data.
    React Native - horizonEnabled=true in Gradle properties. + Resolved at build time by the shared Gradle resolver;{' '} + openiapStore=horizon pins it. The app writes Android manifest meta-data directly.
    Flutter - horizonEnabled=true and app-level{' '} - missingDimensionStrategy. + Resolved at build time by the shared Gradle resolver;{' '} + openiapStore=horizon pins it. Gradle manifest placeholder, usually from local properties. @@ -172,26 +176,27 @@ function HorizonStoreSetup() {
    KMP - Build/publish the Android horizonRelease variant. - The OpenIAP Gradle plugin, as for native Android. The Android host app owns manifest meta-data.
    MAUI - Build Android with OpenIapAndroidStore=horizon,{' '} - meta, or quest. + A connected Quest on a Debug build;{' '} + OpenIapStore=horizon pins it. The Android manifest in the MAUI app owns the app id.
    Godot - No dedicated Horizon flavor switch yet, so there is no Godot - section below. + Left at auto, a debug export follows a connected + Quest; for release, set openiap/android_store to{' '} + horizon. + + The openiap/horizon_app_id export option. Not applicable.
    @@ -202,17 +207,19 @@ function HorizonStoreSetup() { Native Android

    - Use the Horizon artifact directly, or select the local Gradle flavor - when building from source: + Depend on openiap-google and apply the OpenIAP Gradle + plugin; it links openiap-google-horizon instead when a + debug build finds a Quest, or when openiapStore=horizon{' '} + pins a release.

    - {`dependencies { - implementation("io.github.hyochan.openiap:openiap-google-horizon:${OPENIAP_VERSIONS.google}") + {`// settings.gradle.kts +plugins { + id("io.github.hyochan.openiap") version "${OPENIAP_VERSIONS.google}" } -android { - defaultConfig { - missingDimensionStrategy("platform", "horizon") - } +// app/build.gradle.kts +dependencies { + implementation("io.github.hyochan.openiap:openiap-google:${OPENIAP_VERSIONS.google}") }`}

    Provide the app id in the Android manifest:

    {`

    - Expo apps should let the expo-iap config plugin write the - Gradle flavor, dependency, and manifest app id during prebuild: + Keep the app id in the config plugin; the plugin writes the manifest + meta-data on every prebuild and the Gradle build picks the store. An + EAS build has no Quest to follow and a release build never looks, so + pin every EAS profile that must target Horizon in its env + .

    + {`{ + "build": { + "quest": { + "env": { "ORG_GRADLE_PROJECT_openiapStore": "horizon" } + } + } +}`} {`plugins: [ [ 'expo-iap', { - modules: { - horizon: true, - }, android: { horizon: { appId: 'YOUR_HORIZON_APP_ID', @@ -250,27 +264,20 @@ android { React Native

    - react-native-iap has no Expo config plugin, so select the - Horizon flavor in the app Gradle build and write the app id in the - manifest yourself. fireOsEnabled is the switch for{' '} - Amazon Fire OS builds; the - two flavors are mutually exclusive, so keep it false for - Quest artifacts: + react-native-iap has no Expo config plugin, so the app + applies the same resolver script the library uses and writes the app + id into the manifest itself. Nothing below changes between Play and + Quest builds: a horizon flavor, a connected Quest on a + debug build, or -PopeniapStore=horizon picks the store.

    {`# android/gradle.properties -horizonEnabled=true -fireOsEnabled=false horizonAppId=YOUR_HORIZON_APP_ID`} {`// android/app/build.gradle +apply from: new File(project(':react-native-iap').projectDir, 'openiap-store.gradle') + android { defaultConfig { - def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false - def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false - if (horizonEnabled && fireOsEnabled) { - throw new GradleException("horizonEnabled and fireOsEnabled cannot both be true") - } - def flavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') - missingDimensionStrategy "platform", flavor + missingDimensionStrategy "platform", openIapResolveStore('app').store manifestPlaceholders = [ HORIZON_APP_ID: project.findProperty('horizonAppId') ?: '' @@ -287,24 +294,22 @@ android { Flutter

    - Flutter uses the same Gradle property model as bare React Native. The - app module maps the property into the plugin flavor and injects the - app id through a manifest placeholder; localProperties is - the loader the Flutter Android template already defines in{' '} - android/app/build.gradle: + Flutter uses the same resolver as bare React Native. The app module + applies it from the plugin project and injects the app id through a + manifest placeholder; localProperties is the loader the + Flutter Android template already defines in{' '} + android/app/build.gradle. Pin a release build with{' '} + ORG_GRADLE_PROJECT_openiapStore=horizon flutter build apk + .

    - {`# android/gradle.properties -horizonEnabled=true -fireOsEnabled=false`} {`# android/local.properties HORIZON_APP_ID=YOUR_HORIZON_APP_ID`} - {`def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false -def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false -def flavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') + {`apply from: new File(project(':flutter_inapp_purchase').projectDir, 'openiap-store.gradle') +def openIapStore = openIapResolveStore('app', [allowNone: true]).store android { defaultConfig { - missingDimensionStrategy 'platform', flavor + missingDimensionStrategy 'platform', openIapStore == 'none' ? 'play' : openIapStore manifestPlaceholders = [ HORIZON_APP_ID: localProperties.getProperty("HORIZON_APP_ID") ?: "" ] @@ -321,15 +326,36 @@ android { KMP and MAUI

    - KMP publishes per-store Android variants of the library; Quest apps - consume the horizonRelease variant and keep the app id in - the Android host app's manifest, exactly as in the Native Android - section above. When building the library from source, assemble the - variant directly: + KMP apps apply the same plugin in settings.gradle.kts, + which links kmp-iap's Horizon build, and keep the app id in the + Android host app's manifest exactly as in the Native Android + section above. +

    +

    + A MAUI Debug build follows a connected Quest; pin a release with the + MSBuild property. Unlike the other frameworks, every MAUI build also + carries the libraries the other stores need ( + MAUI Setup). +

    + {`dotnet publish -f net10.0-android -c Release -p:OpenIapStore=horizon`} + + +
    + + Godot + +

    + Left at auto, the openiap/android_store{' '} + export option exports the Horizon artifact for a debug export when one + Quest is connected, or the one ANDROID_SERIAL names. A + release export ignores the device, so set the option to{' '} + horizon for release. Put the app id in the{' '} + openiap/horizon_app_id export option; the plugin writes + the manifest meta-data.

    - {`./gradlew :library:assembleHorizonRelease`} -

    MAUI selects the Horizon AAR flavor with an MSBuild property:

    - {`dotnet build -f net10.0-android -p:OpenIapAndroidStore=horizon`} + {`[preset.1.options] +openiap/android_store="horizon" +openiap/horizon_app_id="YOUR_HORIZON_APP_ID"`}
    diff --git a/packages/docs/src/pages/docs/setup/store/index.tsx b/packages/docs/src/pages/docs/setup/store/index.tsx index 027247c5d..c427b3257 100644 --- a/packages/docs/src/pages/docs/setup/store/index.tsx +++ b/packages/docs/src/pages/docs/setup/store/index.tsx @@ -1,7 +1,10 @@ import { Link } from 'react-router-dom'; import AnchorLink from '../../../../components/AnchorLink'; +import Callout from '../../../../components/Callout'; +import CodeBlock from '../../../../components/CodeBlock'; import SEO from '../../../../components/SEO'; import { useScrollToHash } from '../../../../hooks/useScrollToHash'; +import { OPENIAP_VERSIONS } from '../../../../lib/versioning'; function StoreSetup() { useScrollToHash(); @@ -27,6 +30,103 @@ function StoreSetup() { where each framework reads them.

    +
    + + How the Store Is Selected + +

    + Keep every store credential in the project — the Horizon app id and + the Amazon AppstoreAuthenticationKey.pem are inert on the + other stores — and let the build pick the store. The Gradle wrappers + (React Native, Expo, and Flutter) apply the whole rule, first match + wins, and so does the OpenIAP Gradle plugin in native Android and KMP + apps. A Godot export applies it with the{' '} + openiap/android_store export option as the explicit step + and no Variant step, and a MAUI build the same way with the{' '} + OpenIapStore MSBuild property: +

    +
      +
    1. + Explicit —{' '} + openiapStore=play|horizon|amazon as a Gradle property:{' '} + -PopeniapStore=horizon,{' '} + ORG_GRADLE_PROJECT_openiapStore=horizon in an EAS + profile, or gradle.properties. The legacy{' '} + horizonEnabled, fireOsEnabled, and{' '} + openiapPlatform=none still work with a deprecation + warning. +
    2. +
    3. + Variant — the requested task names a store flavor:{' '} + assembleHorizonRelease, installAmazonDebug + , or flutter build apk --flavor amazon in a Flutter app + that declares those flavors. Only a spelled-out task name is read: + once the task graph is ready, an abbreviation such as{' '} + aHR, a lower-cased name or a prefix that builds another + store stops the build instead of shipping the wrong SDK. Name one + store per invocation, and name it play,{' '} + horizon or amazon — the aliases work in{' '} + openiapStore, not in a flavor name. An anchor task such + as assembleDebug or bundleRelease is not a + second store, and it does not give each flavor its own: the build + links the one store this rule chose into every flavor the anchor + produces. To ship a different store per flavor, name one flavor — or + one pin — per invocation. +
    4. +
    5. + Device — debug builds only (a Godot debug export, a + MAUI Debug build): the adb device ANDROID_SERIAL names + (for MAUI, the IDE's AdbTarget first), or the + single attached one, is a Quest or a Fire device. Release builds + never look at a device, and several attached devices select nothing + unless ANDROID_SERIAL picks one. +
    6. +
    7. + Play otherwise. +
    8. +
    +

    + The decision is logged once per build as{' '} + openiap: store=horizon (source=device; ...). A store pin + against a different task flavor, two flavors in one invocation, and a + pin against a legacy flag each fail the build, so a pinned release + train cannot quietly ship the wrong billing SDK. The device is a + fallback rather than a competing signal: a pin or a flavor simply + outranks it. The device step also works with the configuration cache: + plugging in a different device reconfigures the build. The aliases{' '} + google/gplay/googleplay/ + google-play/gms, meta/ + quest, and fire/fireos/ + fire-os normalize to the three store ids. +

    + + Every framework links one store's SDK per build. A MAUI build + additionally carries the NuGet libraries any store needs, because + NuGet fixes a package's dependencies before the build knows the + store: Play Services and DataTransport (about 3.1 MB, with their + manifest entries) and kotlinx-serialization-json (up to 0.9 MB). See{' '} + MAUI Setup. + +

    + Native Android and KMP apps get the rule from the OpenIAP Gradle + plugin, applied once in settings.gradle.kts with{' '} + mavenCentral() in the pluginManagement{' '} + repositories. Depend on openiap-google with a version of + its own, not one only a BOM or constraint supplies; the plugin links + the chosen store's build in its place, and a store artifact + declared directly must name the same store or the build stops. A + module that declares its own platform flavors keeps them: + kmp-iap matches each flavor, and one openiap-google{' '} + dependency links each flavor's store. +

    + {`// settings.gradle.kts +plugins { + id("io.github.hyochan.openiap") version "${OPENIAP_VERSIONS.google}" +}`} +
    Store Targets @@ -43,8 +143,9 @@ function StoreSetup() { Horizon OS - Build the Android Gradle horizon product flavor for - Meta Quest devices. + Resolved at build time: an openiapStore=horizon{' '} + pin, a horizon flavor, or a connected Quest on a + debug build. Horizon OS Setup @@ -53,7 +154,9 @@ function StoreSetup() { Amazon Fire OS - Android amazon flavor for Amazon Appstore builds. + Resolved at build time: an openiapStore=amazon pin, + an amazon flavor, or a connected Fire device on a + debug build. diff --git a/packages/docs/src/pages/docs/types/alternative-billing-types.tsx b/packages/docs/src/pages/docs/types/alternative-billing-types.tsx index 67285d44e..146b87912 100644 --- a/packages/docs/src/pages/docs/types/alternative-billing-types.tsx +++ b/packages/docs/src/pages/docs/types/alternative-billing-types.tsx @@ -264,7 +264,7 @@ val userChoiceListener = object : OpenIapUserChoiceBillingListener { override fun onUserChoiceBilling(details: UserChoiceBillingDetails) { Log.d("IAP", "User chose alternative billing") for (productId in details.products) { - Log.d("IAP", "Product: \$productId") + Log.d("IAP", "Product: $productId") } Log.d("IAP", "External transaction token received; send it to your backend without logging it.") @@ -333,7 +333,7 @@ import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; Future handleUserChoiceBilling(UserChoiceBillingDetails details) async { print('User chose alternative billing'); for (final productId in details.products) { - print('Product: \$productId'); + print('Product: $productId'); } print('External transaction token received; send it to your backend without logging it.'); @@ -351,7 +351,7 @@ Future handleUserChoiceBilling(UserChoiceBillingDetails details) async { final userChoiceSubscription = FlutterInappPurchase.instance.userChoiceBillingAndroid .listen((details) { unawaited(handleUserChoiceBilling(details).catchError( - (Object error) => print('Alternative billing failed: \$error'), + (Object error) => print('Alternative billing failed: $error'), )); }); diff --git a/packages/docs/src/pages/docs/types/external-purchase-link.tsx b/packages/docs/src/pages/docs/types/external-purchase-link.tsx index 53d1dae0e..cfe98c13a 100644 --- a/packages/docs/src/pages/docs/types/external-purchase-link.tsx +++ b/packages/docs/src/pages/docs/types/external-purchase-link.tsx @@ -24,20 +24,12 @@ function ExternalPurchaseLink() {

    iOS-specific feature for redirecting users to an external website for payment using Apple's StoreKit ExternalPurchase API. - Available from iOS 17.4+ (notice sheet) and iOS 18.2+ (custom links). + Available from iOS 17.4+ (notice sheet) and iOS 18.1+ (custom links).

    - Result of presentExternalPurchaseLinkIOS.{' '} - iOS only — wraps{' '} - ExternalPurchaseLink.open(url:) ( - - Apple docs - - ). + presentExternalPurchaseLinkIOS is{' '} + iOS only and opens the URL with{' '} + UIApplication.open, not a StoreKit API.

    Native references:{' '} @@ -105,7 +97,7 @@ function ExternalPurchaseLink() { presentExternalPurchaseLinkIOS Open external purchase URL in Safari - iOS 18.2+ + iOS 16+ @@ -468,7 +460,7 @@ async function handleExternalPurchase(externalUrl: string) { swift: ( {`import OpenIap -@available(iOS 18.2, *) +@available(iOS 17.4, *) func handleExternalPurchase(externalUrl: String) async { do { // Step 1: Check if external purchase is available @@ -638,7 +630,7 @@ async Task HandleExternalPurchaseAsync(string externalUrl) Platform - iOS 17.4+ (notice sheet), iOS 18.2+ (custom links) + iOS 17.4+ (notice sheet), iOS 18.1+ (custom links) Entitlement diff --git a/packages/docs/src/pages/docs/types/index.tsx b/packages/docs/src/pages/docs/types/index.tsx index ce49e24eb..7e2aa1396 100644 --- a/packages/docs/src/pages/docs/types/index.tsx +++ b/packages/docs/src/pages/docs/types/index.tsx @@ -288,7 +288,7 @@ function TypesIndex() { ) { return; } - navigate(redirect, { replace: true }); + void navigate(redirect, { replace: true }); }, [location.hash, location.pathname, navigate]); return ( diff --git a/packages/docs/src/pages/showcase.tsx b/packages/docs/src/pages/showcase.tsx index 40a0a07f6..3f38bc961 100644 --- a/packages/docs/src/pages/showcase.tsx +++ b/packages/docs/src/pages/showcase.tsx @@ -1,13 +1,19 @@ +import type { CSSProperties } from 'react'; import SEO from '../components/SEO'; import { ShowcaseAppCard, ShowcaseSubmitCard, SHOWCASE_GUIDE_URL, SHOWCASE_DISCUSSION_URL, - showcaseGridStyle, } from '../components/ShowcaseCards'; import { SHOWCASE_APPS } from '../lib/showcase'; +const showcaseGridStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: '1rem', +}; + function Showcase() { return (

    diff --git a/packages/google/README.md b/packages/google/README.md index 718056e5a..a3d31bf31 100644 --- a/packages/google/README.md +++ b/packages/google/README.md @@ -48,6 +48,27 @@ dependencies { Use the latest version from [Maven Central](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google) or the badge above. +To ship to Meta Quest or Fire OS as well, apply the OpenIAP Gradle plugin in +`settings.gradle.kts` and keep the dependency above. It links the Horizon or +Amazon build for a debug build on a Quest or Fire device, and for any build that +sets `openiapStore`: + +```kotlin +// settings.gradle.kts +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("io.github.hyochan.openiap") version "" +} +``` + +See [Store Setup](https://openiap.dev/docs/setup/store#selection) for the full rule. + ## Quick Start ```kotlin diff --git a/packages/google/compatibility/store-plugin-agp9/build.gradle b/packages/google/compatibility/store-plugin-agp9/build.gradle new file mode 100644 index 000000000..54c205d0a --- /dev/null +++ b/packages/google/compatibility/store-plugin-agp9/build.gradle @@ -0,0 +1,5 @@ +plugins { + id 'com.android.application' version '9.0.1' apply false + id 'com.android.kotlin.multiplatform.library' version '9.0.1' apply false + id 'org.jetbrains.kotlin.multiplatform' version '2.4.10' apply false +} diff --git a/packages/google/compatibility/store-plugin-agp9/gradle.properties b/packages/google/compatibility/store-plugin-agp9/gradle.properties new file mode 100644 index 000000000..743ad0363 --- /dev/null +++ b/packages/google/compatibility/store-plugin-agp9/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +kotlin.code.style=official diff --git a/packages/google/compatibility/store-plugin-agp9/print-stores.gradle b/packages/google/compatibility/store-plugin-agp9/print-stores.gradle new file mode 100644 index 000000000..d05114900 --- /dev/null +++ b/packages/google/compatibility/store-plugin-agp9/print-stores.gradle @@ -0,0 +1,2 @@ +// The shared modules apply this by their root project's path. +apply from: new File(rootDir, '../store-plugin/print-stores.gradle') diff --git a/packages/google/compatibility/store-plugin-agp9/settings.gradle b/packages/google/compatibility/store-plugin-agp9/settings.gradle new file mode 100644 index 000000000..a64d81c79 --- /dev/null +++ b/packages/google/compatibility/store-plugin-agp9/settings.gradle @@ -0,0 +1,26 @@ +// ../store-plugin's KMP modules on AGP 9, which changed how the KMP library +// plugin selects dependencies. +pluginManagement { + includeBuild('../../gradle-plugin') + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id 'io.github.hyochan.openiap' +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = 'store-plugin-agp9-fixture' +include ':shared', ':consumer' +project(':shared').projectDir = new File(settingsDir, '../store-plugin/shared') +project(':consumer').projectDir = new File(settingsDir, '../store-plugin/consumer') diff --git a/packages/google/compatibility/store-plugin/app/build.gradle b/packages/google/compatibility/store-plugin/app/build.gradle new file mode 100644 index 000000000..5761436ca --- /dev/null +++ b/packages/google/compatibility/store-plugin/app/build.gradle @@ -0,0 +1,37 @@ +// An app with no store setup of its own: the plugin picks the store. +plugins { + id 'com.android.application' +} + +android { + namespace 'dev.hyo.openiap.fixture.app' + compileSdk 36 + defaultConfig { + minSdk 24 + // An app that followed the old setup docs requests the platform itself. + def strategy = project.findProperty('fixtureStrategy') + if (strategy) { + missingDimensionStrategy 'platform', strategy + } + } + // Or a flavor of another dimension requests it for its own variants. + def flavorStrategy = project.findProperty('fixtureFlavorStrategy') + if (flavorStrategy) { + flavorDimensions = ['tier'] + productFlavors { + qa { + dimension 'tier' + missingDimensionStrategy 'platform', flavorStrategy + } + } + } +} + +dependencies { + implementation 'io.github.hyochan.openiap:openiap-google:3.5.2' + implementation 'io.github.hyochan:kmp-iap:3.5.1' +} + +apply from: rootProject.file('print-stores.gradle') +openIapFixturePrint('printDebugStores', 'debugRuntimeClasspath') +openIapFixturePrint('printReleaseStores', 'releaseRuntimeClasspath') diff --git a/packages/google/compatibility/store-plugin/app/src/main/AndroidManifest.xml b/packages/google/compatibility/store-plugin/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/packages/google/compatibility/store-plugin/app/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/store-plugin/build.gradle b/packages/google/compatibility/store-plugin/build.gradle new file mode 100644 index 000000000..8859cb5f0 --- /dev/null +++ b/packages/google/compatibility/store-plugin/build.gradle @@ -0,0 +1,6 @@ +plugins { + id 'com.android.application' version '8.13.2' apply false + id 'com.android.library' version '8.13.2' apply false + id 'com.android.kotlin.multiplatform.library' version '8.13.2' apply false + id 'org.jetbrains.kotlin.multiplatform' version '2.4.10' apply false +} diff --git a/packages/google/compatibility/store-plugin/classic/build.gradle b/packages/google/compatibility/store-plugin/classic/build.gradle new file mode 100644 index 000000000..b6a8768c6 --- /dev/null +++ b/packages/google/compatibility/store-plugin/classic/build.gradle @@ -0,0 +1,27 @@ +// A shared KMP module on the classic com.android.library + androidTarget setup. +plugins { + id 'org.jetbrains.kotlin.multiplatform' + id 'com.android.library' +} + +kotlin { + androidTarget() + sourceSets { + commonMain { + dependencies { + implementation 'io.github.hyochan:kmp-iap:3.5.1' + } + } + } +} + +android { + namespace 'dev.hyo.openiap.fixture.classic' + compileSdk 36 + defaultConfig { + minSdk 24 + } +} + +apply from: rootProject.file('print-stores.gradle') +openIapFixturePrint('printDebugStores', 'debugRuntimeClasspath') diff --git a/packages/google/compatibility/store-plugin/classic/src/androidMain/AndroidManifest.xml b/packages/google/compatibility/store-plugin/classic/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/packages/google/compatibility/store-plugin/classic/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/store-plugin/classic/src/commonMain/kotlin/Classic.kt b/packages/google/compatibility/store-plugin/classic/src/commonMain/kotlin/Classic.kt new file mode 100644 index 000000000..445b18d94 --- /dev/null +++ b/packages/google/compatibility/store-plugin/classic/src/commonMain/kotlin/Classic.kt @@ -0,0 +1 @@ +object Classic diff --git a/packages/google/compatibility/store-plugin/consumer/build.gradle b/packages/google/compatibility/store-plugin/consumer/build.gradle new file mode 100644 index 000000000..15cb6881f --- /dev/null +++ b/packages/google/compatibility/store-plugin/consumer/build.gradle @@ -0,0 +1,19 @@ +// A KMP app module that sees kmp-iap only through :shared. +plugins { + id 'com.android.application' +} + +android { + namespace 'dev.hyo.openiap.fixture.consumer' + compileSdk 36 + defaultConfig { + minSdk 24 + } +} + +dependencies { + implementation project(':shared') +} + +apply from: rootProject.file('print-stores.gradle') +openIapFixturePrint('printDebugStores', 'debugRuntimeClasspath') diff --git a/packages/google/compatibility/store-plugin/consumer/src/main/AndroidManifest.xml b/packages/google/compatibility/store-plugin/consumer/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/packages/google/compatibility/store-plugin/consumer/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/store-plugin/flavored/build.gradle b/packages/google/compatibility/store-plugin/flavored/build.gradle new file mode 100644 index 000000000..960166220 --- /dev/null +++ b/packages/google/compatibility/store-plugin/flavored/build.gradle @@ -0,0 +1,35 @@ +// An app that names the store with its own platform flavors, which the plugin +// leaves alone: kmp-iap matches each flavor, openiap-google is per flavor. +plugins { + id 'com.android.application' +} + +android { + namespace 'dev.hyo.openiap.fixture.flavored' + compileSdk 36 + defaultConfig { + minSdk 24 + } + flavorDimensions = ['platform'] + productFlavors { + play { dimension 'platform' } + horizon { dimension 'platform' } + amazon { dimension 'platform' } + } +} + +dependencies { + // A module can also depend on openiap-google once, for every flavor. + if (project.findProperty('fixturePlainDependency')) { + implementation 'io.github.hyochan.openiap:openiap-google:3.5.2' + } else { + playImplementation 'io.github.hyochan.openiap:openiap-google:3.5.2' + horizonImplementation 'io.github.hyochan.openiap:openiap-google-horizon:3.5.2' + amazonImplementation 'io.github.hyochan.openiap:openiap-google-amazon:3.5.2' + } + implementation 'io.github.hyochan:kmp-iap:3.5.1' +} + +apply from: rootProject.file('print-stores.gradle') +// No store in the task name, so only a pin can make the resolver answer. +openIapFixturePrint('printFlavorStores', 'horizonDebugRuntimeClasspath') diff --git a/packages/google/compatibility/store-plugin/flavored/src/main/AndroidManifest.xml b/packages/google/compatibility/store-plugin/flavored/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/packages/google/compatibility/store-plugin/flavored/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/store-plugin/gradle.properties b/packages/google/compatibility/store-plugin/gradle.properties new file mode 100644 index 000000000..743ad0363 --- /dev/null +++ b/packages/google/compatibility/store-plugin/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +kotlin.code.style=official diff --git a/packages/google/compatibility/store-plugin/mixed/build.gradle b/packages/google/compatibility/store-plugin/mixed/build.gradle new file mode 100644 index 000000000..5bdfcb146 --- /dev/null +++ b/packages/google/compatibility/store-plugin/mixed/build.gradle @@ -0,0 +1,19 @@ +// Still depends on a store artifact directly, as the old setup docs said to. +plugins { + id 'com.android.application' +} + +android { + namespace 'dev.hyo.openiap.fixture.mixed' + compileSdk 36 + defaultConfig { + minSdk 24 + } +} + +dependencies { + implementation 'io.github.hyochan.openiap:openiap-google-horizon:3.5.2' +} + +apply from: rootProject.file('print-stores.gradle') +openIapFixturePrint('printDebugStores', 'debugRuntimeClasspath') diff --git a/packages/google/compatibility/store-plugin/mixed/src/main/AndroidManifest.xml b/packages/google/compatibility/store-plugin/mixed/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/packages/google/compatibility/store-plugin/mixed/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/store-plugin/print-stores.gradle b/packages/google/compatibility/store-plugin/print-stores.gradle new file mode 100644 index 000000000..c6e9aa8d4 --- /dev/null +++ b/packages/google/compatibility/store-plugin/print-stores.gradle @@ -0,0 +1,30 @@ +// Registers a task that prints which OpenIAP store builds a configuration +// resolved, in the one line verify-store-plugin.sh asserts on. +ext.openIapFixturePrint = { String taskName, String configurationName -> + tasks.register(taskName) { + // AGP creates variant configurations after evaluation, so look it up here. + def label = "${project.name}:${configurationName}" + def modules = configurations.named(configurationName).map { configuration -> + def result = configuration.incoming.resolutionResult + // allComponents skips what failed, which would print a partial list. + def failed = result.allDependencies.findAll { it instanceof org.gradle.api.artifacts.result.UnresolvedDependencyResult } + if (failed) { + def causes = failed.collect { dependency -> + def chain = [] + for (def failure = dependency.failure; failure != null; failure = failure.cause) { + chain << failure.message + } + chain.join(' > ') + } + throw new GradleException("${label} failed to resolve: ${causes.join('; ')}") + } + result.allComponents + .collect { it.id } + .findAll { it instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier } + .collect { it.module } + .findAll { it.startsWith('openiap-google') || it.startsWith('kmp-iap-android') } + .sort() + } + doLast { println("FIXTURE ${label}=${modules.get().join(',')}") } + } +} diff --git a/packages/google/compatibility/store-plugin/settings.gradle b/packages/google/compatibility/store-plugin/settings.gradle new file mode 100644 index 000000000..66a2e7e76 --- /dev/null +++ b/packages/google/compatibility/store-plugin/settings.gradle @@ -0,0 +1,24 @@ +// Uses the published openiap-google and kmp-iap as an outside app does, with the +// plugin built from source. +pluginManagement { + includeBuild('../../gradle-plugin') + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id 'io.github.hyochan.openiap' +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = 'store-plugin-fixture' +include ':app', ':flavored', ':shared', ':consumer', ':classic', ':mixed', ':unversioned' diff --git a/packages/google/compatibility/store-plugin/shared/build.gradle b/packages/google/compatibility/store-plugin/shared/build.gradle new file mode 100644 index 000000000..43797a75f --- /dev/null +++ b/packages/google/compatibility/store-plugin/shared/build.gradle @@ -0,0 +1,24 @@ +// A shared KMP module on AGP's Kotlin Multiplatform library plugin, which has +// no defaultConfig and selects dependency flavors its own way. +plugins { + id 'org.jetbrains.kotlin.multiplatform' + id 'com.android.kotlin.multiplatform.library' +} + +kotlin { + androidLibrary { + namespace = 'dev.hyo.openiap.fixture.shared' + compileSdk = 36 + minSdk = 24 + } + sourceSets { + commonMain { + dependencies { + implementation 'io.github.hyochan:kmp-iap:3.5.1' + } + } + } +} + +apply from: rootProject.file('print-stores.gradle') +openIapFixturePrint('printDebugStores', 'androidRuntimeClasspath') diff --git a/packages/google/compatibility/store-plugin/shared/src/commonMain/kotlin/Shared.kt b/packages/google/compatibility/store-plugin/shared/src/commonMain/kotlin/Shared.kt new file mode 100644 index 000000000..40c75e420 --- /dev/null +++ b/packages/google/compatibility/store-plugin/shared/src/commonMain/kotlin/Shared.kt @@ -0,0 +1 @@ +object Shared diff --git a/packages/google/compatibility/store-plugin/unversioned/build.gradle b/packages/google/compatibility/store-plugin/unversioned/build.gradle new file mode 100644 index 000000000..aa7b2664f --- /dev/null +++ b/packages/google/compatibility/store-plugin/unversioned/build.gradle @@ -0,0 +1,20 @@ +// Leaves the openiap-google version to another edge, as a BOM would. +plugins { + id 'com.android.application' +} + +android { + namespace 'dev.hyo.openiap.fixture.unversioned' + compileSdk 36 + defaultConfig { + minSdk 24 + } +} + +dependencies { + implementation 'io.github.hyochan.openiap:openiap-google' + implementation 'io.github.hyochan:kmp-iap:3.5.1' +} + +apply from: rootProject.file('print-stores.gradle') +openIapFixturePrint('printDebugStores', 'debugRuntimeClasspath') diff --git a/packages/google/compatibility/store-plugin/unversioned/src/main/AndroidManifest.xml b/packages/google/compatibility/store-plugin/unversioned/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/packages/google/compatibility/store-plugin/unversioned/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/store-resolver/README.md b/packages/google/compatibility/store-resolver/README.md new file mode 100644 index 000000000..11e48fe1f --- /dev/null +++ b/packages/google/compatibility/store-resolver/README.md @@ -0,0 +1,20 @@ +# Store resolver fixture + +Drives `packages/google/gradle/openiap-store.gradle` — the one rule that +picks the Android store — without an Android SDK, a device, or a network. + +Run the suite that uses it: + +```bash +cd packages/google && bash scripts/verify-store-resolver.sh +``` + +`build.gradle` applies the resolver exactly as a framework wrapper does and +prints one line, `FIXTURE store= source=`, +which the script asserts against. `fake-adb` stands in for adb: each case +exports `FAKE_ADB_DEVICES` and the features and manufacturer those serials +report, so Quest, Fire, and ordinary Android devices are all reproducible here. + +A case belongs here whenever the rule gains a signal, an alias, or a conflict. +A wrong store cannot be seen on the machine that built it; it surfaces only when +the artifact reaches a device whose store the linked SDK cannot serve. diff --git a/packages/google/compatibility/store-resolver/build.gradle b/packages/google/compatibility/store-resolver/build.gradle new file mode 100644 index 000000000..54d338ee3 --- /dev/null +++ b/packages/google/compatibility/store-resolver/build.gradle @@ -0,0 +1,68 @@ +// Applies the resolver exactly as a wrapper does and prints its answer in one +// machine-readable line. verify-store-resolver.sh asserts against that line. +import org.gradle.api.tasks.options.Option + +apply from: new File(rootDir, '../../gradle/openiap-store.gradle') + +def allowNone = (project.findProperty('fixtureAllowNone') ?: 'false').toBoolean() +def resolution = openIapResolveStore('fixture', [allowNone: allowNone]) +// println, not logger: --quiet keeps the run readable but drops lifecycle logs. +println("FIXTURE store=${resolution.store} source=${resolution.source}") + +// A second caller reaches the cached branch, which a single caller never does. +if ((project.findProperty('fixtureSecondCaller') ?: 'false').toBoolean()) { + openIapResolveStore('second', [allowNone: false]) +} + +// Every name Gradle would resolve has to exist, or a case asserts a resolution +// for a build that never ran. +['clean', 'assembleDebug', 'assembleRelease', + 'assemblePlayDebug', 'assemblePlayRelease', + 'assembleAmazonDebug', 'assembleAmazonRelease', + 'assembleHorizonRelease', 'bundleHorizonRelease', + 'installPlayDebug', 'installHorizonDebug', + 'connectedAndroidTest'].each { name -> + tasks.register(name) { doLast { } } +} + +// AGP's lifecycle anchor: one name that builds every flavor. +tasks.register('assemble') { + dependsOn 'assemblePlayRelease', 'assembleAmazonRelease', 'assembleHorizonRelease' + doLast { } +} + +// A custom anchor over two stores, which the graph guard must still allow. +tasks.register('assembleEverything') { + dependsOn 'assembleHorizonRelease', 'assembleAmazonDebug' + doLast { } +} + +// Owns --tests so the filter case is a real invocation, not an unknown option. +abstract class FixtureTestTask extends DefaultTask { + @Input + @Optional + String tests + + @Option(option = 'tests', description = 'Test filter, as Gradle Test declares it') + void setTests(String value) { tests = value } + + @Input + boolean failFast = false + + // Gradle's own Test task declares this one; it takes no value. + @Option(option = 'fail-fast', description = 'Stop at the first failure') + void setFailFast(boolean value) { failFast = value } + + // A value option the resolver does not list, as a plugin's own would be. + @Input + @Optional + String suite + + @Option(option = 'suite', description = 'A value option the resolver does not list') + void setSuite(String value) { suite = value } + + @TaskAction + void run() { } +} + +tasks.register('testDebugUnitTest', FixtureTestTask) diff --git a/packages/google/compatibility/store-resolver/fake-adb b/packages/google/compatibility/store-resolver/fake-adb new file mode 100755 index 000000000..3db7bb42c --- /dev/null +++ b/packages/google/compatibility/store-resolver/fake-adb @@ -0,0 +1,19 @@ +#!/bin/sh +# Stands in for adb so the device rule can be tested without hardware. +# FAKE_ADB_DEVICES: space-separated serials that report as "device". +# FAKE_ADB__FEATURES / _MANUFACTURER: what that serial reports. +serial="" +if [ "$1" = "-s" ]; then serial="$2"; shift 2; fi +case "$1" in + devices) + echo "List of devices attached" + for one in ${FAKE_ADB_DEVICES:-}; do printf '%s\tdevice\n' "$one"; done + ;; + shell) + key=$(printf '%s' "$serial" | tr -c 'A-Za-z0-9' '_') + case "$*" in + *"pm list features"*) eval "printf '%s\n' \"\${FAKE_ADB_${key}_FEATURES:-}\"" ;; + *"ro.product.manufacturer"*) eval "printf '%s\n' \"\${FAKE_ADB_${key}_MANUFACTURER:-}\"" ;; + esac + ;; +esac diff --git a/packages/google/compatibility/store-resolver/settings.gradle b/packages/google/compatibility/store-resolver/settings.gradle new file mode 100644 index 000000000..f9fcab7c6 --- /dev/null +++ b/packages/google/compatibility/store-resolver/settings.gradle @@ -0,0 +1,3 @@ +// Standalone fixture for the store resolver. No Android plugin: the rule under +// test runs entirely at configuration time. +rootProject.name = 'store-resolver-fixture' diff --git a/packages/google/gradle-plugin/build.gradle.kts b/packages/google/gradle-plugin/build.gradle.kts new file mode 100644 index 000000000..418139af0 --- /dev/null +++ b/packages/google/gradle-plugin/build.gradle.kts @@ -0,0 +1,83 @@ +import com.vanniktech.maven.publish.GradlePlugin +import com.vanniktech.maven.publish.JavadocJar +import groovy.json.JsonSlurper + +plugins { + `java-gradle-plugin` + id("com.vanniktech.maven.publish") version "0.37.0" +} + +// Same version line as openiap-google, whose artifacts the plugin selects. +val openIapVersion: String = providers.gradleProperty("openIapVersion").orNull + ?: run { + val versionsFile = rootDir.resolve("../../../openiap-versions.json") + if (!versionsFile.isFile) { + throw GradleException("openiap-gradle-plugin: missing openiap-versions.json at ${versionsFile.path}") + } + (JsonSlurper().parseText(versionsFile.readText()) as Map<*, *>)["google"]?.toString() + ?: throw GradleException("openiap-gradle-plugin: 'google' version missing in openiap-versions.json") + } + +group = "io.github.hyochan.openiap" +version = openIapVersion + +// Java rather than Groovy: a class compiled against Groovy 4 is not guaranteed +// to load under the Groovy 3 runtime Gradle 8 ships. +tasks.withType().configureEach { + options.release.set(11) +} + +gradlePlugin { + website.set("https://openiap.dev/docs/setup/store") + vcsUrl.set("https://github.com/hyodotdev/openiap") + plugins { + create("openiap") { + id = "io.github.hyochan.openiap" + implementationClass = "dev.hyo.openiap.gradle.OpenIapPlugin" + displayName = "OpenIAP store selection" + description = "Links the Play, Horizon, or Amazon build of openiap-google and kmp-iap by the OpenIAP store rule." + } + } +} + +// Ship the resolver the framework wrappers apply rather than a port of it. +tasks.processResources { + from(rootDir.resolve("../gradle/openiap-store.gradle")) { + into("dev/hyo/openiap/gradle") + } +} + +val isCentralPublishTaskRequested = gradle.startParameter.taskNames.any { + it.contains("mavenCentral", ignoreCase = true) +} + +mavenPublishing { + coordinates("io.github.hyochan.openiap", "openiap-gradle-plugin", openIapVersion) + configure(GradlePlugin(javadocJar = JavadocJar.Empty(), sourcesJar = true)) + if (isCentralPublishTaskRequested) { + publishToMavenCentral() + signAllPublications() + } + pom { + name.set("OpenIAP Gradle plugin") + description.set("Selects the Android store build of openiap-google and kmp-iap") + url.set("https://github.com/hyodotdev/openiap") + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + developers { + developer { + id.set("hyochan") + name.set("hyochan") + } + } + scm { + connection.set("scm:git:git://github.com/hyodotdev/openiap.git") + developerConnection.set("scm:git:ssh://git@github.com/hyodotdev/openiap.git") + url.set("https://github.com/hyodotdev/openiap/tree/main/packages/google/gradle-plugin") + } + } +} diff --git a/packages/google/gradle-plugin/settings.gradle.kts b/packages/google/gradle-plugin/settings.gradle.kts new file mode 100644 index 000000000..1744a1b30 --- /dev/null +++ b/packages/google/gradle-plugin/settings.gradle.kts @@ -0,0 +1,15 @@ +// Standalone so apps and fixtures can includeBuild it from pluginManagement. +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "openiap-gradle-plugin" diff --git a/packages/google/gradle-plugin/src/main/java/dev/hyo/openiap/gradle/OpenIapPlugin.java b/packages/google/gradle-plugin/src/main/java/dev/hyo/openiap/gradle/OpenIapPlugin.java new file mode 100644 index 000000000..ffa4d1330 --- /dev/null +++ b/packages/google/gradle-plugin/src/main/java/dev/hyo/openiap/gradle/OpenIapPlugin.java @@ -0,0 +1,70 @@ +package dev.hyo.openiap.gradle; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.initialization.Settings; + +/** + * Applies the OpenIAP store resolver to Android modules and links the store it + * picks. The logic ships as Groovy scripts the consumer's Gradle compiles, so any + * Gradle version works. Applied in settings, it reaches every module. + */ +public final class OpenIapPlugin implements Plugin { + private static final String[] ANDROID_PLUGINS = { + "com.android.application", + "com.android.library", + "com.android.dynamic-feature", + "com.android.test", + "com.android.kotlin.multiplatform.library", + }; + private static final String[] SCRIPTS = {"openiap-store.gradle", "openiap-store-plugin.gradle"}; + + @Override + public void apply(Object target) { + if (target instanceof Settings) { + ((Settings) target).getGradle().allprojects(project -> project.getPluginManager().apply(OpenIapPlugin.class)); + return; + } + if (!(target instanceof Project)) { + throw new GradleException("openiap: apply io.github.hyochan.openiap in settings.gradle(.kts) or an Android module"); + } + Project project = (Project) target; + AtomicBoolean wired = new AtomicBoolean(); + for (String id : ANDROID_PLUGINS) { + project.getPluginManager().withPlugin(id, plugin -> { + if (wired.compareAndSet(false, true)) { + File dir = project.getLayout().getBuildDirectory().dir("openiap").get().getAsFile(); + for (String name : SCRIPTS) { + project.apply(Map.of("from", extract(name, new File(dir, name)))); + } + } + }); + } + } + + private static File extract(String name, File target) { + try (InputStream in = OpenIapPlugin.class.getResourceAsStream(name)) { + if (in == null) { + throw new GradleException("openiap: " + name + " is missing from the plugin jar"); + } + byte[] bytes = in.readAllBytes(); + // Rewriting an unchanged script would needlessly invalidate Gradle's script cache. + if (!target.isFile() || !Arrays.equals(Files.readAllBytes(target.toPath()), bytes)) { + Files.createDirectories(target.getParentFile().toPath()); + Files.write(target.toPath(), bytes); + } + return target; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/packages/google/gradle-plugin/src/main/resources/dev/hyo/openiap/gradle/openiap-store-plugin.gradle b/packages/google/gradle-plugin/src/main/resources/dev/hyo/openiap/gradle/openiap-store-plugin.gradle new file mode 100644 index 000000000..08fef7dcf --- /dev/null +++ b/packages/google/gradle-plugin/src/main/resources/dev/hyo/openiap/gradle/openiap-store-plugin.gradle @@ -0,0 +1,109 @@ +// Applied by the io.github.hyochan.openiap plugin after openiap-store.gradle. Links +// the store the resolver picks into a module that uses the published openiap-google +// or kmp-iap. + +def openIapGroup = 'io.github.hyochan.openiap' +// openiap-google publishes each store as its own artifact. +def openIapArtifactStores = [ + 'openiap-google': 'play', + 'openiap-google-horizon': 'horizon', + 'openiap-google-amazon': 'amazon', +] + +// Swaps openiap-google for the store's artifact, in the given configurations or all. +def openIapUseStore = { String store, Collection targets = null -> + def swap = { configuration -> + configuration.resolutionStrategy.eachDependency { details -> + def named = details.requested.group == openIapGroup ? openIapArtifactStores[details.requested.name] : null + if (named == null || named == store) { + return + } + // A store artifact that names another store fails, as in the resolver. + if (named != 'play') { + throw new GradleException( + "openiap: ${details.requested.name} is declared but this build's store is ${store}; depend on openiap-google, or set openiapStore=${named}" + ) + } + // useTarget needs a version; a BOM's does not carry over to the store's artifact. + def version = details.requested.version + if (!version) { + throw new GradleException( + "openiap: openiap-google is declared without a version but this build's store is ${store}; declare a version so the plugin can link openiap-google-${store}" + ) + } + details.useTarget(group: openIapGroup, name: "openiap-google-${store}", version: version) + details.because("openiap: store=${store}") + } + } + if (targets == null) { + configurations.configureEach(swap) + } else { + targets.each(swap) + } +} + +// The store a flavor's `missingDimensionStrategy 'platform', ...` asks for. The DSL +// has no getter, so read AGP's flavor class; the first store listed wins. +def openIapRequestedPlatform = { flavor -> + if (!flavor.hasProperty('missingDimensionStrategies')) { + if (!binding.hasVariable('openIapStrategyCheckSkipped')) { + binding.setVariable('openIapStrategyCheckSkipped', true) + logger.warn("openiap-store: this AGP hides missingDimensionStrategies, so the strategy-agreement check is skipped") + } + return null + } + def request = flavor.missingDimensionStrategies['platform'] + if (request == null) { + return null + } + return ([request.requested] + request.fallbacks).find { it in openIapArtifactStores.values() } +} + +['com.android.application', 'com.android.library', 'com.android.dynamic-feature', 'com.android.test'].each { id -> + pluginManager.withPlugin(id) { + // With its own platform flavors, each variant links its flavor's store. + androidComponents.onVariants(androidComponents.selector().all()) { variant -> + def flavor = variant.productFlavors.find { it.first == 'platform' }?.second + if (flavor in openIapArtifactStores.values()) { + openIapUseStore(flavor, [variant.runtimeConfiguration, variant.compileConfiguration]) + } + } + androidComponents.finalizeDsl { android -> + if (android.flavorDimensions.contains('platform')) { + if (project.findProperty('openiapStore')) { + logger.lifecycle("openiap: ${project.path} declares platform flavors, so openiapStore does not apply to it") + } + return + } + def store = openIapResolveStore('openiap').store + // A platform the module or a flavor already asks for must match the store. + ([android.defaultConfig] + android.productFlavors.toList()).each { flavor -> + def requested = openIapRequestedPlatform(flavor) + if (requested != null && requested != store) { + def owner = flavor.is(android.defaultConfig) ? project.path : "${project.path} flavor ${flavor.name}" + throw new GradleException( + "openiap: ${owner} sets missingDimensionStrategy platform=${requested} but this build's store is ${store}; remove the strategy and let the plugin set it, or set openiapStore=${requested}" + ) + } + } + // kmp-iap publishes one Android variant per store in this dimension. + android.defaultConfig.missingDimensionStrategy('platform', store) + openIapUseStore(store) + } + } +} + +pluginManager.withPlugin('com.android.kotlin.multiplatform.library') { + def store = openIapResolveStore('openiap').store + kotlin.targets.configureEach { target -> + // AGP 9 removed dependencyVariantSelection; localDependencySelection replaces it. + if (target.hasProperty('localDependencySelection')) { + target.localDependencySelection.productFlavorDimension('platform') { spec -> spec.selectFrom.set([store]) } + } else if (target.hasProperty('dependencyVariantSelection')) { + target.dependencyVariantSelection.productFlavors.put('platform', [store]) + } else if (target.platformType.name() == 'androidJvm') { + logger.warn("openiap: ${project.path} cannot select kmp-iap's ${store} variant on this Android Gradle Plugin; select the platform flavor yourself") + } + } + openIapUseStore(store) +} diff --git a/packages/google/gradle/openiap-store.gradle b/packages/google/gradle/openiap-store.gradle new file mode 100644 index 000000000..74ae57609 --- /dev/null +++ b/packages/google/gradle/openiap-store.gradle @@ -0,0 +1,365 @@ +// OpenIAP store resolver. Framework wrappers symlink this file into android/ +// and publish it as a real file. +// Apply it, then call openIapResolveStore('