diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index cb617d4f..b07c0ed1 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -34,7 +34,7 @@ Complete workflow: branch → commit → push → PR If the staged changes only touch internal agent/workflow files, do not push or create a PR unless the user explicitly asked to publish, PR, or merge them. Internal workflow files include `.claude/commands/`, `.codex/skills/`, -`AGENTS.md`, `CLAUDE.md`, and agent automation notes. +`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and agent automation notes. For those internal-only changes, prefer a local commit or local working-tree change and report the files changed. If the user explicitly asks to open or diff --git a/.claude/commands/e2e-tests.md b/.claude/commands/e2e-tests.md new file mode 100644 index 00000000..6425ddff --- /dev/null +++ b/.claude/commands/e2e-tests.md @@ -0,0 +1,681 @@ +# E2E Tests — Device-Backed OpenIAP Regression + +Run this when a PR or release candidate needs real-device regression across +OpenIAP native packages and framework examples. + +## Scope + +Use the narrowest scope that satisfies the request, but broaden when native +build configuration, dependency placement, config plugins, package manifests, +or store-specific behavior changed. + +- **Full PR regression**: package checks, native package flavor checks, and + every supported library row in the matrix below. Do not stop after + React Native and Expo unless the user explicitly narrows the scope. +- **Build-only regression**: all relevant compile/build commands, no purchase + dialogs. Clearly report that purchase flows were not exercised. +- **Store-flow regression**: build/install each requested target, then verify + product fetch, purchase, finish/consume, restore/get available purchases, and + cancel/error handling where the example UI exposes them. +- **Vega/FireOS dependency regression**: run normal Expo/RN iOS and Android + builds first, then FireOS/Amazon and Vega builds. This catches accidental + Kepler dependency leakage. + +## Full Regression Matrix + +When `e2e-tests` is requested without a narrower scope, run every applicable +row and report every unavailable row as `BLOCKED` or `UNSUPPORTED` with the +exact missing command, tool, device, or store prerequisite. + +| Target | Android / Play | FireOS / Amazon | Horizon | iOS | VegaOS | Onside | +| ------ | -------------- | --------------- | ------- | --- | ------ | ------ | +| `packages/google` native | build + tests | build + tests | build-only | n/a | n/a | n/a | +| `packages/apple` native | n/a | n/a | n/a | build + tests | n/a | n/a | +| `react-native-iap` | build + device flow | build + device flow | build-only | build + device flow | RN only | n/a | +| `expo-iap` | build + device flow | build + device flow | build-only | build + device flow | Expo only | build-only | +| `flutter_inapp_purchase` | build + device flow | build + device flow | build-only | build + device flow | n/a | n/a | +| `kmp-iap` | build + device flow | required row; currently blocked unless a FireOS flavor is wired | required row; currently blocked unless a Horizon flavor is wired | build + device flow | n/a | n/a | +| `maui-iap` | build + device flow | required row; currently blocked unless an Amazon binding flavor is wired | required row; currently blocked unless a Horizon binding flavor is wired | build + device flow | n/a | n/a | +| `godot-iap` | build + device flow | n/a | n/a | build + device flow | n/a | n/a | + +Notes: + +- VegaOS is required only for `react-native-iap` and `expo-iap`. +- Godot is required only on Android and iOS. +- Horizon is build-only unless the user explicitly provides a Horizon device and + the library has a runnable Horizon example. +- Onside is Expo-only and build-only; do not require Onside purchase approval. +- KMP and MAUI must still appear in the final report for FireOS/Horizon. If the + repo has no flavor switch for those libraries, mark the row blocked instead of + treating the Play Android build as coverage. + +## Rules + +- Check `git status --short --branch` before changes. Do not revert user + changes. +- Do not report a platform as green without a passing command or a concrete + manual-device result. +- If a build fails, fix it and rerun the failing command plus the adjacent + platform command that could regress. +- Use connected physical devices when available and record serials/UDIDs. +- Approve sandbox/test purchase dialogs only when the current user request + explicitly authorizes purchase approval. +- Treat missing store account, catalog, entitlement, tester app, or device + connectivity as blocked, not passed. +- For VegaOS, source `~/vega/env` before invoking `vega` when present. +- For VegaOS, `vega exec vda devices -l` is transport visibility only. Treat + `kepler device list` or `vega device list` as the install/launch source of + truth because plain VDA can also list Android and FireOS devices. +- For VegaOS, do not assume Android `screencap`, `input`, or `scrcpy` works. + Prefer `kepler device launch-app`, `is-app-running`, `running-apps`, and logs; + require user/capture-device visual confirmation for screen-only purchase UI. +- For Expo Vega, verify normal Expo iOS/Android manifests do not require + Vega-only dependencies unless `amazon.vegaOS=true` or a generated Vega temp + target requires them. +- For React Native Vega, remember there is no Expo config plugin. RN users need + a Vega-only target/package manifest; normal iOS/Android manifests must not + require Kepler packages. + +## Preflight + +Run from the repo root: + +```bash +git status --short --branch +git branch --show-current +git log --oneline --decorate -5 +if command -v adb >/dev/null 2>&1; then + adb devices -l +else + echo "adb: not found" +fi +if command -v xcrun >/dev/null 2>&1; then + xcrun devicectl list devices +else + echo "xcrun: not found" +fi +if [ -f "$HOME/vega/env" ]; then source "$HOME/vega/env"; fi +if command -v vega >/dev/null 2>&1; then + vega -v --json + vega exec vda devices -l + vega device list || true + if command -v kepler >/dev/null 2>&1; then + kepler --version + kepler device list || true + fi +else + echo "vega: not found" +fi +node -v +bun -v +PATH="$HOME/.bun/bin:$PATH" bun -v +(cd libraries/react-native-iap && yarn -v) +java -version +if command -v flutter >/dev/null 2>&1; then + flutter --version +else + echo "flutter: not found" +fi +if command -v dotnet >/dev/null 2>&1; then + dotnet --info +else + echo "dotnet: not found" +fi +if command -v godot >/dev/null 2>&1; then + godot --version +else + echo "godot: not found" +fi +if command -v xcodebuild >/dev/null 2>&1; then + xcodebuild -version +else + echo "xcodebuild: not found" +fi +``` + +If the repo expects the bundled Bun version, prefer this from the repo root: + +```bash +PATH="$HOME/.bun/bin:$PATH" bun run audit:parity +``` + +## VegaOS Device Checks + +Run this before any Vega build/install claim: + +```bash +if [ -f "$HOME/vega/env" ]; then source "$HOME/vega/env"; fi +vega exec vda start-server +vega exec vda devices -l +kepler device list || vega device list +: "${VEGA_DEVICE_ID:?Set VEGA_DEVICE_ID from kepler/vega device list}" +kepler device info -d "$VEGA_DEVICE_ID" +kepler device installed-packages -d "$VEGA_DEVICE_ID" | grep -E \ + 'dev\.hyo\.openiap|dev\.hyo\.martie|com\.amazonappstore\.iap\.tester|com\.amazon\.iap\.core' +kepler device installed-apps -d "$VEGA_DEVICE_ID" | grep -E \ + 'dev\.hyo\.openiap|dev\.hyo\.martie|com\.amazonappstore\.iap\.tester|com\.amazon\.iap\.tester' +``` + +USB and TCP/IP setup: + +```bash +# USB mode should make the Fire TV / VegaOS device appear in both commands. +vega exec vda devices -l +kepler device list || vega device list + +# TCP/IP mode uses the device IP as the VDA device id after connect. +# Use -s only when multiple VDA devices are attached over USB. +vega exec vda -s "$VEGA_DEVICE_ID" tcpip 5555 +vega exec vda connect "$VEGA_DEVICE_HOST:5555" +vega exec vda devices -l +kepler device list || vega device list +``` + +VegaOS screen and input caveat: + +```bash +# These may fail on VegaOS. Failure does not block build/install/run checks. +vega exec vda -s "$VEGA_DEVICE_ID" shell screencap -p > /tmp/vega.png || true +scrcpy -s "$VEGA_DEVICE_ID" --no-audio --time-limit=5 || true +``` + +If screen capture is unavailable, record app state with: + +```bash +kepler device running-apps -d "$VEGA_DEVICE_ID" +kepler device is-app-running -d "$VEGA_DEVICE_ID" -a dev.hyo.openiap.expo.example.main +kepler device is-app-running -d "$VEGA_DEVICE_ID" -a dev.hyo.openiap.rniap.example.main +kepler device start-log-stream -d "$VEGA_DEVICE_ID" +``` + +If Vega install hangs or fails, keep build and install results separate: + +```bash +kepler device install-app \ + -d "$VEGA_DEVICE_ID" \ + --packagePath build/armv7-debug/expoiapvegaexample_armv7.vpkg +kepler device installed-packages -d "$VEGA_DEVICE_ID" | grep 'dev.hyo.openiap' +tail -n 120 "$HOME/vega/sdk/vega-sdk/main/0.22.6759/logs/error-$(date +%F).log" +``` + +If `install-app` or synchronous `vpm install` returns `Internal Error` while +`vpm print --manifest` still works, inspect and cancel only the stale OpenIAP +install tokens, then retry with async high-priority install: + +```bash +vega exec vda -s "$VEGA_DEVICE_ID" shell vpm query-installs | grep openiap +vega exec vda -s "$VEGA_DEVICE_ID" shell vpm cancel-download "$OPENIAP_INSTALL_TOKEN" --timeout=5 +vega exec vda -s "$VEGA_DEVICE_ID" push \ + build/armv7-debug/expoiapvegaexample_armv7.vpkg \ + /tmp/expoiapvegaexample_armv7.vpkg +vega exec vda -s "$VEGA_DEVICE_ID" shell vpm install-async \ + /tmp/expoiapvegaexample_armv7.vpkg \ + --token=openiap_expo_$(date +%s) \ + --timeout=60 \ + --high-priority \ + --force \ + --update-max-timeout=5 \ + --terminate-on-max-timeout +``` + +Report `vpm install` internal errors, App Tester scratch permission errors, or +missing package rows as install failures, not build failures. + +## Package-Level Checks + +Google Android package: + +```bash +cd packages/google +./gradlew :openiap:compilePlayDebugKotlin \ + :openiap:compileHorizonDebugKotlin \ + :openiap:compileAmazonDebugKotlin \ + :Example:compileAmazonDebugKotlin \ + :Example:compileHorizonDebugKotlin \ + :Example:compilePlayDebugKotlin \ + :openiap:test +``` + +Apple package: + +```bash +cd packages/apple +swift build +swift test +# Required when iOS native packaging/bindings changed or MAUI/Godot iOS rows +# consume the xcframework. +bash scripts/build-xcframework.sh +``` + +Docs and parity when docs, API surface, generated types, examples, or package +parity changed: + +```bash +# Run from the repo root. +PATH="$HOME/.bun/bin:$PATH" bun audit:docs +PATH="$HOME/.bun/bin:$PATH" bun audit:parity +``` + +## Expo Checks + +Library and plugin: + +```bash +cd libraries/expo-iap +bun run lint:tsc +cd plugin +bunx jest --runInBand +``` + +Example tests, normal Android build, and launch smoke: + +```bash +cd libraries/expo-iap/example +bun run test --runInBand +# If Expo config, plugin, or native dependency wiring changed, run +# `bunx expo prebuild --platform android --clean` instead. +test -d android || bunx expo prebuild --platform android +cd android +./gradlew :app:assembleDebug +# Build-only regression can stop here. +: "${ANDROID_SERIAL:?Set ANDROID_SERIAL to the target Android device serial}" +adb -s "$ANDROID_SERIAL" install -r app/build/outputs/apk/debug/app-debug.apk +adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.openiap.expo.example 1 +``` + +Normal iOS physical-device build and launch smoke: + +```bash +cd libraries/expo-iap/example +: "${IOS_UDID:?Set IOS_UDID to the target iOS device UDID}" +: "${TEAM_ID:?Set TEAM_ID to the Apple development team ID}" +xcodebuild \ + -workspace ios/expoiapexample.xcworkspace \ + -configuration Debug \ + -scheme expoiapexample \ + -destination "id=$IOS_UDID" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + -derivedDataPath build/DerivedData \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration +# Build-only regression can stop here. +xcrun devicectl device install app \ + --device "$IOS_UDID" \ + build/DerivedData/Build/Products/Debug-iphoneos/expoiapexample.app +xcrun devicectl device process launch \ + --device "$IOS_UDID" \ + dev.hyo.openiap.expo.example +``` + +FireOS/Amazon Android path: + +```bash +cd libraries/expo-iap/example +EXPO_IAP_FIREOS=1 bunx expo prebuild --platform android --clean +cd android +./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 +adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.openiap.expo.example 1 +``` + +Horizon Android build-only path: + +```bash +cd libraries/expo-iap/example +EXPO_IAP_HORIZON=1 bunx expo prebuild --platform android --clean +cd android +./gradlew :app:assembleDebug +``` + +Onside iOS build-only path: + +```bash +cd libraries/expo-iap/example +EXPO_IAP_ONSIDE=1 bunx expo prebuild --platform ios --clean +xcodebuild \ + -workspace ios/expoiapexample.xcworkspace \ + -configuration Debug \ + -scheme expoiapexample \ + -destination "generic/platform=iOS" \ + CODE_SIGNING_ALLOWED=NO +``` + +VegaOS/Kepler path: + +```bash +cd libraries/expo-iap/example +if [ -f "$HOME/vega/env" ]; then source "$HOME/vega/env"; fi +bun run build:vega:debug +bun run build:vega:release +# Build-only regression can stop here. +: "${VEGA_DEVICE_ID:?Set VEGA_DEVICE_ID to the target Vega device ID}" +VEGA_DEVICE_ID="$VEGA_DEVICE_ID" bun run run:vega:firetv +kepler device is-app-running \ + -d "$VEGA_DEVICE_ID" \ + -a dev.hyo.openiap.expo.example.main +``` + +## React Native Checks + +Library: + +```bash +cd libraries/react-native-iap +yarn lint:tsc +yarn test:library --runInBand +``` + +Normal Android build and launch smoke: + +```bash +cd libraries/react-native-iap/example/android +./gradlew :app:assembleDebug +# Build-only regression can stop here. +: "${ANDROID_SERIAL:?Set ANDROID_SERIAL to the target Android device serial}" +adb -s "$ANDROID_SERIAL" install -r app/build/outputs/apk/debug/app-debug.apk +adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1 +``` + +FireOS/Amazon Android build and launch smoke: + +```bash +cd libraries/react-native-iap/example/android +./gradlew :app:assembleDebug -PfireOsEnabled=true +# 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 +adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1 +``` + +Horizon Android build-only path: + +```bash +cd libraries/react-native-iap/example/android +./gradlew :app:assembleDebug -PhorizonEnabled=true +``` + +Normal iOS physical-device build and launch smoke: + +```bash +cd libraries/react-native-iap/example +: "${IOS_UDID:?Set IOS_UDID to the target iOS device UDID}" +: "${TEAM_ID:?Set TEAM_ID to the Apple development team ID}" +xcodebuild \ + -workspace ios/example.xcworkspace \ + -configuration Debug \ + -scheme example \ + -destination "id=$IOS_UDID" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + -derivedDataPath build/DerivedData \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration +# Build-only regression can stop here. +xcrun devicectl device install app \ + --device "$IOS_UDID" \ + build/DerivedData/Build/Products/Debug-iphoneos/example.app +xcrun devicectl device process launch \ + --device "$IOS_UDID" \ + dev.hyo.martie +``` + +VegaOS/Kepler path: + +```bash +cd libraries/react-native-iap/example +if [ -f "$HOME/vega/env" ]; then source "$HOME/vega/env"; fi +yarn build:vega:debug +yarn build:vega:release +# Build-only regression can stop here. +: "${VEGA_DEVICE_ID:?Set VEGA_DEVICE_ID to the target Vega device ID}" +VEGA_DEVICE_ID="$VEGA_DEVICE_ID" yarn run:vega:firetv +kepler device is-app-running \ + -d "$VEGA_DEVICE_ID" \ + -a dev.hyo.openiap.rniap.example.main +``` + +## Flutter Checks + +Library and Dart tests: + +```bash +cd libraries/flutter_inapp_purchase +flutter pub get +git ls-files '*.dart' | grep -v '^lib/types.dart$' | \ + xargs dart format --page-width 80 --output=none --set-exit-if-changed +flutter analyze +flutter test +``` + +Normal Android build and launch smoke: + +```bash +cd libraries/flutter_inapp_purchase/example +flutter pub get +flutter build apk --debug +# Build-only regression can stop here. +: "${ANDROID_SERIAL:?Set ANDROID_SERIAL to the target Android device serial}" +adb -s "$ANDROID_SERIAL" install -r build/app/outputs/flutter-apk/app-debug.apk +adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1 +``` + +FireOS/Amazon Android build and launch smoke: + +```bash +cd libraries/flutter_inapp_purchase/example/android +./gradlew :app:assembleDebug -PfireOsEnabled=true +# 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 +adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1 +``` + +Horizon Android build-only path: + +```bash +cd libraries/flutter_inapp_purchase/example/android +./gradlew :app:assembleDebug -PhorizonEnabled=true +``` + +iOS physical-device build and launch smoke: + +```bash +cd libraries/flutter_inapp_purchase/example +flutter build ios --debug --no-codesign +# Build-only regression can stop here. +: "${IOS_UDID:?Set IOS_UDID to the target iOS device UDID}" +flutter run -d "$IOS_UDID" --debug +``` + +## KMP Checks + +Library build and tests: + +```bash +cd libraries/kmp-iap +./gradlew :library:build :library:test :library:podspec :library:generateDummyFramework +``` + +Normal Android build and launch smoke: + +```bash +cd libraries/kmp-iap/example +./gradlew :composeApp:assembleDebug +# Build-only regression can stop here. +: "${ANDROID_SERIAL:?Set ANDROID_SERIAL to the target Android device serial}" +adb -s "$ANDROID_SERIAL" install -r composeApp/build/outputs/apk/debug/composeApp-debug.apk +adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1 +``` + +iOS physical-device build and launch smoke: + +```bash +cd libraries/kmp-iap/example/iosApp +: "${IOS_UDID:?Set IOS_UDID to the target iOS device UDID}" +: "${TEAM_ID:?Set TEAM_ID to the Apple development team ID}" +xcodebuild \ + -project iosApp.xcodeproj \ + -configuration Debug \ + -scheme iosApp \ + -destination "id=$IOS_UDID" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + -derivedDataPath build/DerivedData \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration +``` + +FireOS/Amazon and Horizon rows: + +```text +KMP currently has no FireOS/Amazon or Horizon flavor switch in +libraries/kmp-iap/library/build.gradle.kts or example/composeApp/build.gradle.kts. +Report these rows as BLOCKED until the flavor is wired; do not count the normal +Android build as FireOS or Horizon coverage. +``` + +## MAUI Checks + +Library build and native binding prerequisites: + +```bash +cd packages/google +./gradlew :openiap:assemblePlayRelease +cd ../../libraries/maui-iap/android +../../../packages/google/gradlew :openiap:assembleRelease +cd ../../.. +bash packages/apple/scripts/build-xcframework.sh +cd libraries/maui-iap +dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -f net9.0 +dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -f net9.0-android +dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -f net9.0-ios +``` + +Normal Android build and launch smoke: + +```bash +cd libraries/maui-iap/example/OpenIap.Maui.Example +dotnet build -t:Run -f net9.0-android +``` + +iOS physical-device build and launch smoke: + +```bash +cd libraries/maui-iap/example/OpenIap.Maui.Example +: "${IOS_UDID:?Set IOS_UDID to the target iOS device UDID}" +dotnet build -t:Run -f net9.0-ios -p:_DeviceName="$IOS_UDID" +``` + +FireOS/Amazon and Horizon rows: + +```text +MAUI currently builds its Android binding against the Play flavor. It has no +Amazon or Horizon binding flavor switch. Report FireOS/Horizon rows as BLOCKED +until libraries/maui-iap/android and the binding csproj expose those variants. +``` + +## Godot Checks + +Godot is Android/iOS only for this e2e matrix. + +Android build and launch smoke: + +```bash +cd libraries/godot-iap +make setup +make android +# Build-only regression can stop here. +: "${ANDROID_SERIAL:?Set ANDROID_SERIAL to the target Android device serial}" +make export-android +adb -s "$ANDROID_SERIAL" install -r Example/android/Martie.apk +adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1 +``` + +iOS build and launch smoke: + +```bash +cd libraries/godot-iap +make setup +make ios +make export-ios +: "${IOS_UDID:?Set IOS_UDID to the target iOS device UDID}" +: "${TEAM_ID:?Set TEAM_ID to the Apple development team ID}" +xcodebuild \ + -project Example/ios/Martie.xcodeproj \ + -configuration Debug \ + -scheme Martie \ + -destination "id=$IOS_UDID" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + -derivedDataPath Example/ios/DerivedData \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration +``` + +## Manual Store Flows + +Run these on each connected platform requested: + +- Android phone / Google Play: install the Play build, launch, fetch products, + buy a consumable, approve sandbox purchase if authorized, verify purchase + update and finish/consume behavior, then test restore/available purchases if + exposed. +- FireOS tablet / Amazon Appstore tester: install the Amazon/FireOS build, copy + tester catalog/config if needed, launch, fetch products, buy a consumable, + approve tester purchase if authorized, verify no crash on success, cancel, or + timeout/error paths. +- iPhone / StoreKit sandbox: install the iOS build, launch, fetch products, buy + a visible consumable or subscription, approve sandbox purchase if authorized, + verify transaction finish and restore behavior. +- VegaOS / Fire TV: build and install the VPK, copy Amazon tester catalog/config + with the project script, launch, fetch products, and run at least one purchase + attempt when device input and tester UI are available. +- Horizon: build-only unless a runnable Horizon device and store prerequisites + are explicitly available. If it is build-only, do not claim purchase flow + coverage. +- Onside: Expo iOS build-only. Verify the generated Podfile and iOS project + include the Onside module when `EXPO_IAP_ONSIDE=1`; do not claim purchase + flow coverage unless the Onside app/account/test flow was actually exercised. + +If a product catalog or sandbox account is missing, stop that row and report it +as blocked with the exact missing prerequisite. + +## Final Report + +Use this compact matrix: + +```text +Platform/package | Target/device | Command/flow | Result | Notes +packages/google Play | local Gradle | compile/test | PASS | ... +packages/google Fire | local Gradle | compile/test | PASS | ... +packages/google Horz | local Gradle | compile | PASS | build-only +packages/apple iOS | local SwiftPM | build/test | PASS | ... +RN Android | {serial} | build/install/purchase | PASS | ... +RN FireOS | {serial} | build/install/purchase | PASS | ... +RN Horizon | local Gradle | build | PASS | build-only +RN iOS | {UDID} | build/install/purchase | PASS | ... +RN Vega | {device id} | build debug/release/run | PASS | ... +Expo Android | {serial} | build/install/purchase | PASS | ... +Expo FireOS | {serial} | build/install/purchase | PASS | ... +Expo Horizon | local Gradle | build | PASS | build-only +Expo iOS | {UDID} | build/install/purchase | PASS | ... +Expo Onside | generic iOS | prebuild/build | PASS | build-only +Expo Vega | {device id} | build debug/release/run | PASS | ... +Flutter Android | {serial} | build/install/purchase | PASS | ... +Flutter FireOS | {serial} | build/install/purchase | PASS | ... +Flutter Horizon | local Gradle | build | PASS | build-only +Flutter iOS | {UDID} | build/install/purchase | PASS | ... +KMP Android | {serial} | build/install/purchase | PASS | ... +KMP FireOS | n/a | flavor check | BLOCKED | not wired +KMP Horizon | n/a | flavor check | BLOCKED | not wired +KMP iOS | {UDID} | build/install/purchase | PASS | ... +MAUI Android | {serial} | build/run/purchase | PASS | ... +MAUI FireOS | n/a | flavor check | BLOCKED | not wired +MAUI Horizon | n/a | flavor check | BLOCKED | not wired +MAUI iOS | {UDID} | build/run/purchase | PASS | ... +Godot Android | {serial} | build/install/purchase | PASS | ... +Godot iOS | {UDID} | build/install/purchase | PASS | ... +``` + +Always list untested rows. Do not collapse "build passed" and "purchase flow +passed" into one claim unless both actually ran. For unsupported rows, include +the exact file or build script that lacks the required target switch. diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md index 8e1c7c4e..d32efb1d 100644 --- a/.claude/commands/verify-all.md +++ b/.claude/commands/verify-all.md @@ -87,8 +87,9 @@ All release workflows exist and have valid YAML: ls .github/workflows/release-{apple,google,react-native,expo,flutter,godot,kmp}.yml ``` -### 8. CLAUDE.md -- Root CLAUDE.md lists all 5 library CLAUDE.md files +### 8. Agent instructions +- Root AGENTS.md lists all framework library CLAUDE.md files +- Root CLAUDE.md and GEMINI.md are symlinks to AGENTS.md - `knowledge/internal/02-architecture.md` includes `libraries/` in structure - Auto-generated files list includes library types diff --git a/.codex/skills/openiap-workflows/SKILL.md b/.codex/skills/openiap-workflows/SKILL.md index a426eccd..e05ecd37 100644 --- a/.codex/skills/openiap-workflows/SKILL.md +++ b/.codex/skills/openiap-workflows/SKILL.md @@ -1,19 +1,19 @@ --- name: openiap-workflows -description: Use for OpenIAP monorepo work that should follow the repository's Claude slash-command workflows, including review-pr, audit-code, compile-knowledge, verify-all, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from CLAUDE.md/AGENTS.md. +description: Use for OpenIAP monorepo work that should follow the repository's Claude slash-command workflows, including review-pr, audit-code, compile-knowledge, verify-all, e2e-tests, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md/CLAUDE.md/GEMINI.md. --- # OpenIAP Workflows Use this skill when the user asks Codex to perform an OpenIAP repo workflow that previously lived under `.claude/commands`, such as reviewing a PR, resolving an -issue, auditing code, compiling knowledge, verifying the monorepo, or committing -and opening a PR. +issue, auditing code, compiling knowledge, running device-backed E2E regression, +verifying the monorepo, or committing and opening a PR. ## Source Of Truth -Before changing code, read the root `AGENTS.md` or `CLAUDE.md`; they are linked -in this repo. Then read the relevant detailed files: +Before changing code, read the root `AGENTS.md`; `CLAUDE.md` and `GEMINI.md` +are symlinks to it in this repo. Then read the relevant detailed files: - Package and library rules: `knowledge/internal/*.md` - Package conventions: `packages/*/CONVENTION.md` @@ -37,17 +37,23 @@ natural-language requests, execute the matching workflow: - Resolve a GitHub issue: read `.claude/commands/resolve-issue.md`. - Verify all, health check, or pre-PR verification: read `.claude/commands/verify-all.md`. +- E2E tests, device regression, connected-device purchase flow checks, or + "e2e-tests": read `.claude/commands/e2e-tests.md`. - Commit, push, or create PR: read `.claude/commands/commit.md`. When a command file gives a sequence, follow it unless the user's newest 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. + ## Internal Workflow Change Guard Internal agent/workflow-only changes include `.claude/commands/`, -`.codex/skills/`, `AGENTS.md`, `CLAUDE.md`, and agent automation notes. Do not -create a branch, push, or open a PR for those changes unless the user explicitly -asks to publish, PR, or merge them. +`.codex/skills/`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and agent automation +notes. Do not create a branch, push, or open a PR for those changes unless the +user explicitly asks to publish, PR, or merge them. If a user asks to update an internal workflow and does not explicitly ask for a PR, keep the change local and report the changed files. If a PR is already open diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e633774a..77e7661c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,10 @@ jobs: working-directory: packages/google run: ./gradlew :openiap:assembleHorizonDebug + - name: Build Fire OS flavor + working-directory: packages/google + run: ./gradlew :openiap:assembleAmazonDebug + test-ios: name: Test iOS runs-on: macos-15 diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml index 04d3445a..1a9b24fe 100644 --- a/.github/workflows/release-google.yml +++ b/.github/workflows/release-google.yml @@ -290,6 +290,38 @@ jobs: echo "✅ Published openiap-google-horizon (Horizon flavor) to Maven Central" fi + - name: Check if Fire OS flavor already published + id: check_amazon + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if curl -fsI "https://repo1.maven.org/maven2/io/github/hyochan/openiap/openiap-google-amazon/$VERSION/" >/dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "⚠️ openiap-google-amazon $VERSION already exists on Maven Central" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "✓ openiap-google-amazon $VERSION does not exist, will publish" + fi + + - name: Publish Fire OS flavor to Maven Central + if: steps.check_amazon.outputs.exists == 'false' + working-directory: packages/google + env: + ORG_GRADLE_PROJECT_openIapVersion: ${{ steps.version.outputs.version }} + ORG_GRADLE_PROJECT_OPENIAP_PUBLISH_VARIANT: amazon + 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: | + if [ -z "$ORG_GRADLE_PROJECT_mavenCentralUsername" ]; then + echo "⚠️ Maven Central credentials not set. Skipping publish." + else + ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace + echo "✅ Published openiap-google-amazon (Fire OS flavor) to Maven Central" + fi + - name: Check if Play flavor already published id: check_play env: @@ -381,7 +413,7 @@ jobs: cat > /tmp/release-notes.md <` | +| `/audit-code` | Audit code against knowledge rules and latest APIs | `/audit-code` | +| `/compile-knowledge` | Compile knowledge base for Claude context | `/compile-knowledge` | +| `/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` | +| `/commit` | Branch, commit, push, and optionally create PR | `/commit --all --pr` | + +### /review-pr Workflow + +1. Fetches unresolved PR review threads +2. For each comment: + - **Valid issue** → Fix code + - **Invalid/wrong** → Reply with explanation (don't resolve) +3. **Run lint, typecheck, tests** (BEFORE commit) +4. If all pass → Commit and push +5. Resolve fixed threads + +## For More Details + +All comprehensive rules are documented in [`knowledge/internal/`](knowledge/internal/): + +1. **01-naming-conventions.md** - Function naming, prefixes, file naming, URL anchors +2. **02-architecture.md** - Monorepo structure, module patterns, async patterns +3. **03-coding-style.md** - TypeScript/Swift/Kotlin style rules, error handling +4. **04-platform-packages.md** - Apple/Google/GQL/Docs package workflows +5. **05-docs-patterns.md** - React modal patterns, component organization +6. **06-git-deployment.md** - Commit format, deployment workflows +7. **07-docs-consistency.md** - Docs/API/type consistency audits +8. **08-gv-cloud-workspaces.md** - Safe TabTabTab `gv` cloud workspace policy diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 4e47d82a..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,194 +0,0 @@ -# OpenIAP Monorepo - Agent Guidelines - -This document provides an overview for AI agents working across the OpenIAP monorepo. - -**All detailed rules are in the `knowledge/internal/` folder** - this is the Single Source of Truth (SSOT). - -## Quick Reference - -| Topic | File | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Naming Conventions | [`knowledge/internal/01-naming-conventions.md`](knowledge/internal/01-naming-conventions.md) | -| Architecture | [`knowledge/internal/02-architecture.md`](knowledge/internal/02-architecture.md) | -| Coding Style | [`knowledge/internal/03-coding-style.md`](knowledge/internal/03-coding-style.md) | -| Platform Packages | [`knowledge/internal/04-platform-packages.md`](knowledge/internal/04-platform-packages.md) (run `bun audit:parity` before commits; pre-commit mirrors CI's SDK parity job) | -| Docs Patterns | [`knowledge/internal/05-docs-patterns.md`](knowledge/internal/05-docs-patterns.md) | -| Git & Deployment | [`knowledge/internal/06-git-deployment.md`](knowledge/internal/06-git-deployment.md) | -| Docs Consistency / SSOT | [`knowledge/internal/07-docs-consistency.md`](knowledge/internal/07-docs-consistency.md) (run `bun audit:docs` before pushing API/Type doc edits) | -| GV Cloud Workspaces | [`knowledge/internal/08-gv-cloud-workspaces.md`](knowledge/internal/08-gv-cloud-workspaces.md) | - -## Monorepo Structure - -```text -openiap/ -├── packages/ -│ ├── docs/ # Documentation site (React/Vite/Vercel) -│ ├── gql/ # GraphQL schema & type generation -│ ├── google/ # Android library -│ ├── apple/ # iOS/macOS library -│ └── kit/ # Hosted receipt-validation SaaS (Fly.io app) -├── libraries/ # Framework SDK implementations -│ ├── react-native-iap/ # React Native (npm) -│ ├── expo-iap/ # Expo (npm) -│ ├── flutter_inapp_purchase/ # Flutter (pub.dev) -│ ├── godot-iap/ # Godot 4.x (GitHub Release) -│ ├── kmp-iap/ # Kotlin Multiplatform (Maven Central) -│ └── maui-iap/ # .NET MAUI / C# (NuGet — scaffold) -├── knowledge/ # Shared knowledge base (SSOT) -│ ├── internal/ # Project philosophy (HIGHEST PRIORITY) -│ ├── external/ # External API reference -│ └── _claude-context/ # Compiled context for Claude Code -├── scripts/ # Monorepo-wide automation -└── .github/workflows/ # CI/CD workflows -``` - -## Required Pre-Work - -**CRITICAL**: Before writing or editing anything in a package or library: - -1. **Read the relevant knowledge files** from `knowledge/internal/` - - When the GraphQL schema adds or changes an API, follow the **SDK Parity Checklist** in [`knowledge/internal/04-platform-packages.md`](knowledge/internal/04-platform-packages.md#sdk-parity-checklist-critical--prevents-declared-but-not-implemented) to avoid phantom interfaces (declared in types but never wired end-to-end — the class of bug behind GitHub issue #104). -2. **Check the package-specific CONVENTION.md**: - - [`packages/gql/CONVENTION.md`](packages/gql/CONVENTION.md) - - [`packages/google/CONVENTION.md`](packages/google/CONVENTION.md) - - [`packages/apple/CONVENTION.md`](packages/apple/CONVENTION.md) - - [`packages/kit/CONVENTION.md`](packages/kit/CONVENTION.md) — kit is a deployable SaaS (not a library); has its own Convex schema and isn't part of the GQL type-sync chain -3. **For framework libraries, read the library-specific CLAUDE.md**: - - [`libraries/react-native-iap/CLAUDE.md`](libraries/react-native-iap/CLAUDE.md) — Yarn 3, Nitro Modules, useIAP hook semantics, error handling - - [`libraries/expo-iap/CLAUDE.md`](libraries/expo-iap/CLAUDE.md) — Bun, Expo Modules, iOS podspec 13.4 workaround, tvOS 16.0 requirement - - [`libraries/flutter_inapp_purchase/CLAUDE.md`](libraries/flutter_inapp_purchase/CLAUDE.md) — Flutter/Dart, generated types.dart, fetchProducts generic API - - [`libraries/godot-iap/CLAUDE.md`](libraries/godot-iap/CLAUDE.md) — GDScript conventions, GDExtension (iOS), AAR plugin (Android) - - [`libraries/kmp-iap/CLAUDE.md`](libraries/kmp-iap/CLAUDE.md) — Kotlin Multiplatform, Flow-based API, CocoaPods iOS integration - - [`libraries/maui-iap/CLAUDE.md`](libraries/maui-iap/CLAUDE.md) — .NET MAUI / C# 12, generated Types.cs, Xamarin binding plan - -## Key Rules Summary - -### Platform Function Naming - -- **iOS functions**: Must end with `IOS` suffix (e.g., `syncIOS`, `getStorefrontIOS`) -- **Android functions in packages/google**: NO `Android` suffix (it's Android-only) -- **Cross-platform functions**: NO suffix - -### Auto-Generated Files (DO NOT EDIT) - -- `packages/gql/src/generated/*` - All generated type files (SSOT) -- `packages/apple/Sources/Models/Types.swift` - Synced from GQL -- `packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt` - Synced from GQL -- `libraries/react-native-iap/src/types.ts` - Synced from GQL -- `libraries/expo-iap/src/types.ts` - Synced from GQL -- `libraries/flutter_inapp_purchase/lib/types.dart` - Synced from GQL -- `libraries/godot-iap/addons/godot-iap/types.gd` - Synced from GQL -- `libraries/maui-iap/src/OpenIap.Maui/Types.cs` - Synced from GQL -- `openiap-versions.json` - Managed by CI/CD workflows only; tracks only `spec`, `google`, and `apple` - -Framework library package versions (React Native, Expo, Flutter, Godot, KMP, -MAUI) live in their own package metadata / release workflows. Do not add -framework-library version keys to `openiap-versions.json`. - -When writing release notes or `Package Releases` lists, verify framework -versions from each library's metadata and verify published links with GitHub -release tags. Do not infer framework versions from `openiap-versions.json` or -copy nearby release blocks without checking the actual package/tag. - -Regenerate and sync types: - -```bash -cd packages/gql && bun run generate # Generate types from GraphQL schema -cd ../.. && ./scripts/sync-versions.sh # Sync to all packages and libraries -``` - -### GQL Code Generation System - -The type generation uses an **IR-based (Intermediate Representation)** architecture: - -```text -GraphQL Schema → Parser → IR → Language Plugins → Generated Code - ↓ - codegen/core/ codegen/plugins/ - ├── types.ts ├── swift.ts - ├── parser.ts ├── kotlin.ts - └── transformer.ts├── dart.ts - ├── gdscript.ts - └── csharp.ts -``` - -**Language plugins handle:** - -- **Swift**: Codable protocol, ErrorCode custom initializer, platform defaults -- **Kotlin**: sealed interface, fromJson/toJson with nullable patterns -- **Dart**: sealed class, factory constructors, extends/implements -- **GDScript**: \_init() pattern, Variant type for unions -- **C#**: sealed records, per-enum JsonConverter, [JsonPolymorphic] unions - -### Git Commit Format - -- With tag: `feat: add new feature` (lowercase after tag) -- Without tag: `Add new feature` (uppercase first letter) - -## Using Claude Code with Context - -```bash -cd scripts/agent - -# Compile for AI assistants (no Ollama required) -bun run compile:ai - -# Or compile for both Claude Code + Local RAG -bun run compile - -# Use with Claude Code -claude --context knowledge/_claude-context/context.md -``` - -## Codex Compatibility - -`AGENTS.md` is a symlink to this file, so Codex reads the same root project -instructions as Claude Code. The `.claude/commands/` files remain the workflow -SSOT for slash-command-style tasks. - -Codex supports Skills through `SKILL.md` folders. This repo provides a -Codex-compatible local skill at `.codex/skills/openiap-workflows/` that maps the -Claude slash-command workflows to Codex natural-language requests. - -Install it into your local Codex home when needed: - -```bash -./.codex/scripts/install-skills.sh -``` - -After installation, ask Codex normally (for example, "review PR 65" or -"resolve issue 88"), or explicitly mention `$openiap-workflows`. - -## Available Skills (Slash Commands / Codex Workflows) - -| Skill | Description | Usage | -| -------------------- | -------------------------------------------------- | ------------------------------------- | -| `/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` | -| `/compile-knowledge` | Compile knowledge base for Claude context | `/compile-knowledge` | -| `/resolve-issue` | Analyze an issue, label it, and fix/comment | `/resolve-issue 88` | -| `/verify-all` | Run the full monorepo health check | `/verify-all` | -| `/commit` | Branch, commit, push, and optionally create PR | `/commit --all --pr` | - -### /review-pr Workflow - -1. Fetches unresolved PR review threads -2. For each comment: - - **Valid issue** → Fix code - - **Invalid/wrong** → Reply with explanation (don't resolve) -3. **Run lint, typecheck, tests** (BEFORE commit) -4. If all pass → Commit and push -5. Resolve fixed threads - -## For More Details - -All comprehensive rules are documented in [`knowledge/internal/`](knowledge/internal/): - -1. **01-naming-conventions.md** - Function naming, prefixes, file naming, URL anchors -2. **02-architecture.md** - Monorepo structure, module patterns, async patterns -3. **03-coding-style.md** - TypeScript/Swift/Kotlin style rules, error handling -4. **04-platform-packages.md** - Apple/Google/GQL/Docs package workflows -5. **05-docs-patterns.md** - React modal patterns, component organization -6. **06-git-deployment.md** - Commit format, deployment workflows -7. **07-docs-consistency.md** - Docs/API/type consistency audits -8. **08-gv-cloud-workspaces.md** - Safe TabTabTab `gv` cloud workspace policy diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md index 681311eb..47dc3e3d 120000 --- a/GEMINI.md +++ b/GEMINI.md @@ -1 +1 @@ -CLAUDE.md \ No newline at end of file +AGENTS.md \ No newline at end of file diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 9b62d064..0974d0ad 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-05-16T12:59:43.317Z +> Last updated: 2026-06-10T16:35:32.512Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -583,6 +583,35 @@ let userId = '123'; var config = { timeout: 5000 }; ``` +### Keep Single-Use Helpers Local + +Private helper functions used by only one function should be declared inside +that function so their scope matches their real ownership. Keep helpers at file +scope only when they are exported, reused by multiple call sites, or need a +stable top-level identity for tests, recursion, or platform registration. + +```typescript +// ✅ CORRECT - helper is owned by getResolved() +function getResolved(): ResolvedModule { + function getExpectedModuleName(): NativeModuleName { + return isVegaOS() ? 'ExpoIapVega' : 'ExpoIap'; + } + + const expectedName = getExpectedModuleName(); + return resolve(expectedName); +} + +// ❌ INCORRECT - helper has only one call site but lives at file scope +function getExpectedModuleName(): NativeModuleName { + return isVegaOS() ? 'ExpoIapVega' : 'ExpoIap'; +} + +function getResolved(): ResolvedModule { + const expectedName = getExpectedModuleName(); + return resolve(expectedName); +} +``` + ### Prefer Interface Over Type for Objects ```typescript @@ -861,6 +890,12 @@ The mechanical guardrail for this checklist is: bun run audit:parity ``` +This mirrors CI's **Audit SDK Parity** job and is intentionally run by the +pre-commit hook on every commit. Do not bypass it for docs/version-only changes: +the audit also checks generated docs version metadata and the Godot Android +GDAP dependency pin against `openiap-versions.json`, so release-version drift can +break CI even when no SDK source code changed. + This audit treats `libraries/expo-iap/example` as the non-Godot example SSOT and fails when: @@ -871,11 +906,17 @@ and fails when: - a GraphQL Query/Mutation/Subscription operation is added or removed without updating the operation parity registry - generated types or shared TS runtime helpers drift from `packages/gql` - -Run it after type generation and before opening a PR for SDK/API/example -changes. If it fails for a newly introduced operation or feature, update the -missing SDK bridge/example/test coverage first, then update the parity registry -in [`scripts/audit-non-godot-parity.mjs`](../../scripts/audit-non-godot-parity.mjs). +- framework/package version metadata or Godot Android GDAP dependencies drift + from the package/version SSOTs + +Run it after type generation, after version syncs, and before opening a PR for +SDK/API/example/docs-version changes. If it fails for a newly introduced +operation or feature, update the missing SDK bridge/example/test coverage first, +then update the parity registry in +[`scripts/audit-non-godot-parity.mjs`](../../scripts/audit-non-godot-parity.mjs). +If it fails for Godot GDAP dependency drift, run +`./libraries/godot-iap/scripts/write-gdap.sh` and commit the regenerated +`libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap`. ### The bug pattern @@ -992,6 +1033,13 @@ The Google package supports **two build flavors**: 2. Put reusable Kotlin helpers in `openiap/src/main/java/dev/hyo/openiap/utils/` 3. Run `./scripts/generate-types.sh` to regenerate types 4. **Test BOTH flavors** when making changes to shared code +5. **Never persist local receipt-to-SKU aliases as entitlement identity**: + store-specific adapters may cache data for performance or correlate an + in-flight request by request ID, but they must not permanently rewrite + `productId`, `currentPlanId`, or entitlement state from app-local alias + storage. Subscription and entitlement state must come from the store response, + restore/query APIs, or Kit/server verification so client state cannot drift + from server truth. ### Build Commands @@ -1759,7 +1807,7 @@ Use these checks before writing a release list: If the release is not published yet, use planned wording and plain text. If the release is published, verify the tag exists with `gh release view ` before linking it. This prevents stale Package Releases tables such as documenting -`maui-iap 1.0.1` when the actual release tag is `maui-iap-1.0.2`. +`maui-iap 1.0.1` when the actual release tag is `maui-iap-1.0.3`. --- @@ -1864,6 +1912,7 @@ react-native-iap / godot-iap, then the Apple wrapper must also default to description is the canonical statement. When changing a default, update: + 1. The GraphQL schema description. 2. Re-run `bun run generate`. 3. Every wrapper SDK's `?? ` expression and JSDoc / KDoc / etc. @@ -1878,6 +1927,7 @@ The audit script greps for fields that don't appear in the type definition and flags them. Example failure modes already encountered: + - `BillingProgramAvailabilityResultAndroid` doc listed `responseCode` + `debugMessage` — neither field exists; the type has `billingProgram` + `isAvailable`. @@ -1903,6 +1953,7 @@ the union is `'browser'` only, but the doc claimed Anchor links should point to existing pages and section anchors. Common recent failures: + - "Use verifyPurchase" link pointed to `/docs/apis/get-active-subscriptions` (totally unrelated). - `getExternalPurchaseCustomLinkTokenIOS` Returns linked to the @@ -1930,6 +1981,7 @@ exactly as Google / Apple states it. Code examples in doc pages should at minimum parse / type-check against the wrapper they target. The audit script does NOT yet run a full TypeScript / Kotlin / Dart parser, but it does: + - Verify imports (`import {…} from 'expo-iap'`) reference symbols that expo-iap actually exports. - Verify field accesses on shown objects (e.g. `purchase.purchaseToken`) @@ -1961,6 +2013,27 @@ the GitHub Release does not exist yet. `bun run audit:docs` fails bare package/version entries under published `Package Releases` blocks so link regressions are caught before publishing. +### R10 — Docs version metadata stays synced with package metadata + +`packages/docs/src/lib/versioning.ts` must not import package metadata from +outside `packages/docs`. Vercel uploads the docs package root, so imports such +as `../../../../libraries/expo-iap/package.json?raw` pass locally but fail in +Vercel builds. + +Framework package versions and Android SDK constants used by docs must flow +through `packages/docs/src/generated/version-metadata.json`, which is generated +by `scripts/sync-versions.sh` from the real SSOT files: + +- Expo / React Native: each library `package.json` +- Flutter: `libraries/flutter_inapp_purchase/pubspec.yaml` +- Godot: `libraries/godot-iap/addons/godot-iap/plugin.cfg` +- KMP: `libraries/kmp-iap/gradle.properties` and `gradle/libs.versions.toml` +- MAUI: `libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj` +- Google Android SDK / Play Billing: `packages/google/openiap/build.gradle.kts` + +`bun run audit:docs` fails if this generated metadata drifts from the SSOT +files or if `versioning.ts` reintroduces raw imports outside `packages/docs`. + ## Pre-commit checklist Run before every `git push` on docs / SDK changes: @@ -1990,6 +2063,7 @@ positives in CI. `scripts/audit-docs.ts` is the executable companion to this guide. It parses every `/docs/apis/*.tsx` and `/docs/types/*.tsx` page, extracts: + - `` targets - `fieldName` mentions inside Returns / Parameters tables - String-literal enum values in `'…'` blocks diff --git a/knowledge/internal/03-coding-style.md b/knowledge/internal/03-coding-style.md index cff0e053..e2b3c238 100644 --- a/knowledge/internal/03-coding-style.md +++ b/knowledge/internal/03-coding-style.md @@ -89,6 +89,35 @@ let userId = '123'; var config = { timeout: 5000 }; ``` +### Keep Single-Use Helpers Local + +Private helper functions used by only one function should be declared inside +that function so their scope matches their real ownership. Keep helpers at file +scope only when they are exported, reused by multiple call sites, or need a +stable top-level identity for tests, recursion, or platform registration. + +```typescript +// ✅ CORRECT - helper is owned by getResolved() +function getResolved(): ResolvedModule { + function getExpectedModuleName(): NativeModuleName { + return isVegaOS() ? 'ExpoIapVega' : 'ExpoIap'; + } + + const expectedName = getExpectedModuleName(); + return resolve(expectedName); +} + +// ❌ INCORRECT - helper has only one call site but lives at file scope +function getExpectedModuleName(): NativeModuleName { + return isVegaOS() ? 'ExpoIapVega' : 'ExpoIap'; +} + +function getResolved(): ResolvedModule { + const expectedName = getExpectedModuleName(); + return resolve(expectedName); +} +``` + ### Prefer Interface Over Type for Objects ```typescript diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index d814869e..fbdae882 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -266,6 +266,13 @@ The Google package supports **two build flavors**: 2. Put reusable Kotlin helpers in `openiap/src/main/java/dev/hyo/openiap/utils/` 3. Run `./scripts/generate-types.sh` to regenerate types 4. **Test BOTH flavors** when making changes to shared code +5. **Never persist local receipt-to-SKU aliases as entitlement identity**: + store-specific adapters may cache data for performance or correlate an + in-flight request by request ID, but they must not permanently rewrite + `productId`, `currentPlanId`, or entitlement state from app-local alias + storage. Subscription and entitlement state must come from the store response, + restore/query APIs, or Kit/server verification so client state cannot drift + from server truth. ### Build Commands diff --git a/knowledge/internal/06-git-deployment.md b/knowledge/internal/06-git-deployment.md index d10ea6b0..652b5dae 100644 --- a/knowledge/internal/06-git-deployment.md +++ b/knowledge/internal/06-git-deployment.md @@ -186,7 +186,7 @@ Use these checks before writing a release list: If the release is not published yet, use planned wording and plain text. If the release is published, verify the tag exists with `gh release view ` before linking it. This prevents stale Package Releases tables such as documenting -`maui-iap 1.0.1` when the actual release tag is `maui-iap-1.0.2`. +`maui-iap 1.0.1` when the actual release tag is `maui-iap-1.0.3`. --- diff --git a/libraries/expo-iap/.npmignore b/libraries/expo-iap/.npmignore index 893cee37..74184c52 100644 --- a/libraries/expo-iap/.npmignore +++ b/libraries/expo-iap/.npmignore @@ -13,3 +13,7 @@ __tests__ /android/build/ /example/ /docs/ +/coverage/ +/.nyc_output/ +*.lcov +*.tsbuildinfo diff --git a/libraries/expo-iap/android/build.gradle b/libraries/expo-iap/android/build.gradle index e4fd012b..4050e94c 100644 --- a/libraries/expo-iap/android/build.gradle +++ b/libraries/expo-iap/android/build.gradle @@ -50,6 +50,13 @@ 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') + // 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. // Most of the time, you may like to manage the Android SDK versions yourself. @@ -76,17 +83,14 @@ android { versionCode = 1 versionName = expoIapPackageVersion // When using local openiap-google with flavors, select the appropriate flavor - // Read horizonEnabled from gradle.properties, default to play - def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false - def flavor = horizonEnabled ? 'horizon' : 'play' - missingDimensionStrategy "platform", flavor + missingDimensionStrategy "platform", openiapFlavor } - lintOptions { + lint { abortOnError = false } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } } @@ -100,12 +104,12 @@ kotlin { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7" - // Determine which OpenIAP dependency to use based on horizonEnabled flag - def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false - // Use OpenIAP Google module only; avoid direct BillingClient dependency 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}" diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapLog.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapLog.kt index 4c124c2b..d2d1ed9b 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapLog.kt @@ -3,9 +3,25 @@ package expo.modules.iap import android.util.Log import org.json.JSONArray import org.json.JSONObject +import java.util.Locale internal object ExpoIapLog { private const val TAG = "ExpoIap" + private val SENSITIVE_KEY_FRAGMENTS = setOf( + "token", + "apikey", + "secret", + "jws", + "receiptid", + "userid", + "password", + "bearer" + ) + private val SENSITIVE_AUTH_KEYS = setOf( + "auth", + "authorization", + "authheader" + ) fun payload( name: String, @@ -61,10 +77,16 @@ internal object ExpoIapLog { } private fun sanitizeMap(source: Map<*, *>): Map { + fun isSensitiveKey(key: String): Boolean { + val normalized = key.lowercase(Locale.ROOT).filter { it.isLetterOrDigit() } + return SENSITIVE_KEY_FRAGMENTS.any { normalized.contains(it) } || + normalized in SENSITIVE_AUTH_KEYS + } + val sanitized = linkedMapOf() for ((rawKey, rawValue) in source) { val key = rawKey as? String ?: continue - if (key.lowercase().contains("token")) { + if (isSensitiveKey(key)) { sanitized[key] = "hidden" continue } 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 2f08fb94..4639a0f2 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 @@ -3,6 +3,7 @@ package expo.modules.iap import android.content.Context import dev.hyo.openiap.AndroidSubscriptionOfferInput import dev.hyo.openiap.DeepLinkOptions +import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions import dev.hyo.openiap.InitConnectionConfig @@ -183,9 +184,9 @@ class ExpoIapModule : Module() { val result = openIap.fetchProducts(request) val payload = when (result) { + is FetchProductsResultAll -> result.value.orEmpty().map { it.toJson() } is FetchProductsResultProducts -> result.value.orEmpty().map { it.toJson() } is FetchProductsResultSubscriptions -> result.value.orEmpty().map { it.toJson() } - else -> emptyList>() } ExpoIapLog.result("fetchProducts", payload) promise.resolve(payload) @@ -495,7 +496,13 @@ class ExpoIapModule : Module() { } AsyncFunction("verifyPurchaseWithProvider") { params: Map, promise: Promise -> - ExpoIapLog.payload("verifyPurchaseWithProvider", params) + ExpoIapLog.payload( + "verifyPurchaseWithProvider", + mapOf( + "provider" to params["provider"], + "hasIapkit" to (params["iapkit"] != null), + ), + ) scope.launch { try { val props = diff --git a/libraries/expo-iap/bun.lock b/libraries/expo-iap/bun.lock index 7488173d..50926c28 100644 --- a/libraries/expo-iap/bun.lock +++ b/libraries/expo-iap/bun.lock @@ -20,10 +20,16 @@ "ts-jest": "^29.4.1", }, "peerDependencies": { + "@amazon-devices/keplerscript-appstore-iap-lib": "~2.12.13", + "@amazon-devices/package-manager-lib": "~1.0.1767254401", "expo": "*", "react": "*", "react-native": "*", }, + "optionalPeers": [ + "@amazon-devices/keplerscript-appstore-iap-lib", + "@amazon-devices/package-manager-lib", + ], }, }, "packages": { diff --git a/libraries/expo-iap/example/.env.example b/libraries/expo-iap/example/.env.example index 619c5fa5..0ab5d186 100644 --- a/libraries/expo-iap/example/.env.example +++ b/libraries/expo-iap/example/.env.example @@ -1,3 +1,6 @@ # IAPKit Configuration # Get your API key from https://kit.openiap.dev EXPO_PUBLIC_IAPKIT_API_KEY=your_iapkit_api_key_here +# Use your Mac's LAN IP for Vega / Fire TV device testing. +# Example: http://192.168.0.10:3100 +EXPO_PUBLIC_IAPKIT_BASE_URL= diff --git a/libraries/expo-iap/example/.gitignore b/libraries/expo-iap/example/.gitignore index f18c21fd..2d2fee8c 100644 --- a/libraries/expo-iap/example/.gitignore +++ b/libraries/expo-iap/example/.gitignore @@ -19,6 +19,13 @@ expo-env.d.ts android/ ios/ +# Vega generated by the expo-iap config plugin +manifest.toml +app.json +index.js +buildinfo.json +assets/image/ + # Metro .metro-health-check* diff --git a/libraries/expo-iap/example/App.kepler.tsx b/libraries/expo-iap/example/App.kepler.tsx new file mode 100644 index 00000000..01e9a559 --- /dev/null +++ b/libraries/expo-iap/example/App.kepler.tsx @@ -0,0 +1,170 @@ +import React, {useMemo, useState} from 'react'; +import { + LogBox, + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import {ActionSheetProvider} from '@expo/react-native-action-sheet'; +import AllProducts from './app/all-products'; +import AlternativeBilling from './app/alternative-billing'; +import AvailablePurchases from './app/available-purchases'; +import Home from './app/index'; +import OfferCode from './app/offer-code'; +import PurchaseFlow from './app/purchase-flow'; +import SubscriptionFlow from './app/subscription-flow'; +import WebhookStream from './app/webhook-stream'; +import {ExpoRouterShimProvider} from './vega-shims/expo-router'; + +LogBox.ignoreLogs([ + 'Legacy AsyncStorage is on a deprecation path', + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + '[AmazonIAPSDK] Response status for GetUserData : FAILED', + '[AmazonIAPSDK] Response status for GetProductData : FAILED', + '[AmazonIAPSDK] Response status for GetPurchaseUpdates : FAILED', + '[Expo-IAP] Error fetching products:', + '[Expo-IAP] Error fetching available purchases:', + '[Expo-IAP] Error getting active subscriptions:', +]); + +(globalThis as { + EXPO_IAP_ENABLE_TV_SHORTCUTS?: boolean; + EXPO_IAP_SUPPRESS_NATIVE_ALERTS?: boolean; +}).EXPO_IAP_ENABLE_TV_SHORTCUTS = true; + +(globalThis as { + EXPO_IAP_ENABLE_TV_SHORTCUTS?: boolean; + EXPO_IAP_SUPPRESS_NATIVE_ALERTS?: boolean; +}).EXPO_IAP_SUPPRESS_NATIVE_ALERTS = true; + +type RoutePath = + | '/' + | '/all-products' + | '/purchase-flow' + | '/subscription-flow' + | '/available-purchases' + | '/offer-code' + | '/alternative-billing' + | '/webhook-stream'; + +const ROUTE_TITLES: Record = { + '/': 'expo-iap Examples', + '/all-products': 'All Products', + '/purchase-flow': 'In-App Purchase Flow', + '/subscription-flow': 'Subscription Flow', + '/available-purchases': 'Available Purchases', + '/offer-code': 'Offer Code Redemption', + '/alternative-billing': 'Alternative Billing', + '/webhook-stream': 'Webhook Stream', +}; + +const SCREENS: Record, React.ComponentType> = { + '/all-products': AllProducts, + '/purchase-flow': PurchaseFlow, + '/subscription-flow': SubscriptionFlow, + '/available-purchases': AvailablePurchases, + '/offer-code': OfferCode, + '/alternative-billing': AlternativeBilling, + '/webhook-stream': WebhookStream, +}; + +const normalizeRoute = (href: unknown): RoutePath => { + const route = typeof href === 'string' ? href : '/'; + return route in ROUTE_TITLES ? (route as RoutePath) : '/'; +}; + +export default function App(): React.JSX.Element { + const [stack, setStack] = useState(['/']); + const route = stack[stack.length - 1] ?? '/'; + const canGoBack = stack.length > 1; + + const navigation = useMemo( + () => ({ + navigate(href: unknown) { + const nextRoute = normalizeRoute(href); + setStack((currentStack) => [...currentStack, nextRoute]); + }, + replace(href: unknown) { + const nextRoute = normalizeRoute(href); + setStack((currentStack) => [...currentStack.slice(0, -1), nextRoute]); + }, + back() { + setStack((currentStack) => + currentStack.length > 1 ? currentStack.slice(0, -1) : currentStack, + ); + }, + }), + [], + ); + + const Screen = route === '/' ? null : SCREENS[route]; + + return ( + + + + {canGoBack ? ( + + + Back + + + {ROUTE_TITLES[route]} + + + + ) : null} + + + {route === '/' ? : Screen ? : null} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F8FAFC', + }, + content: { + flex: 1, + }, + header: { + alignItems: 'center', + backgroundColor: '#fff', + borderBottomColor: '#E2E8F0', + borderBottomWidth: 1, + flexDirection: 'row', + minHeight: 56, + paddingHorizontal: 12, + }, + backButton: { + minWidth: 72, + paddingHorizontal: 12, + paddingVertical: 10, + }, + backButtonText: { + color: '#2563EB', + fontSize: 16, + fontWeight: '600', + }, + headerTitle: { + color: '#0F172A', + flex: 1, + fontSize: 18, + fontWeight: '700', + textAlign: 'center', + }, + headerSpacer: { + minWidth: 72, + }, +}); diff --git a/libraries/expo-iap/example/__tests__/alternative-billing.test.tsx b/libraries/expo-iap/example/__tests__/alternative-billing.test.tsx new file mode 100644 index 00000000..d3e9f08f --- /dev/null +++ b/libraries/expo-iap/example/__tests__/alternative-billing.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import {fireEvent, render} from '@testing-library/react-native'; +import {Platform} from 'react-native'; +import AlternativeBilling from '../app/alternative-billing'; +import * as ExpoIap from '../../src'; + +describe('AlternativeBilling Component', () => { + const originalPlatform = Platform.OS; + const mockFetchProducts = jest.fn(() => Promise.resolve([])); + const mockFinishTransaction = jest.fn(() => Promise.resolve()); + + beforeEach(() => { + jest.clearAllMocks(); + (ExpoIap.useIAP as jest.Mock).mockReturnValue({ + connected: true, + products: [ + { + id: 'dev.hyo.martie.consumable', + title: 'Test Consumable', + description: 'Test consumable description', + displayPrice: '$0.99', + type: 'in-app', + }, + ], + fetchProducts: mockFetchProducts, + finishTransaction: mockFinishTransaction, + }); + }); + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { + get: jest.fn(() => originalPlatform), + configurable: true, + }); + }); + + it('renders Amazon Vega as unsupported for alternative billing', () => { + Object.defineProperty(Platform, 'OS', { + get: jest.fn(() => 'kepler'), + configurable: true, + }); + + const {getByText} = render(); + + expect(getByText('Not supported on Amazon Vega')).toBeDefined(); + expect( + getByText(/Alternative billing APIs are intentionally unsupported/), + ).toBeDefined(); + expect(getByText('Current mode: Amazon Vega standard IAP')).toBeDefined(); + + fireEvent.press(getByText('Test Consumable')); + + expect(getByText('Not supported on Vega')).toBeDefined(); + }); +}); diff --git a/libraries/expo-iap/example/__tests__/available-purchases.test.tsx b/libraries/expo-iap/example/__tests__/available-purchases.test.tsx new file mode 100644 index 00000000..59161255 --- /dev/null +++ b/libraries/expo-iap/example/__tests__/available-purchases.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import {fireEvent, render, waitFor} from '@testing-library/react-native'; +import {Platform} from 'react-native'; +import AvailablePurchases from '../app/available-purchases'; +import * as ExpoIap from '../../src'; + +describe('AvailablePurchases Component', () => { + const originalPlatform = Platform.OS; + const mockFetchProducts = jest.fn(() => Promise.resolve([])); + const mockGetAvailablePurchases = jest.fn(() => Promise.resolve([])); + const mockGetActiveSubscriptions = jest.fn(() => Promise.resolve([])); + const mockFinishTransaction = jest.fn(() => Promise.resolve()); + + beforeEach(() => { + jest.clearAllMocks(); + (ExpoIap.useIAP as jest.Mock).mockReturnValue({ + connected: true, + subscriptions: [ + { + id: 'dev.hyo.martie.premium', + title: 'Premium Subscription', + description: 'Premium features', + displayPrice: '$9.99', + type: 'subs', + }, + ], + availablePurchases: [ + { + productId: 'dev.hyo.martie.premium', + id: 'transaction-1', + transactionDate: Date.now(), + platform: 'android', + }, + ], + activeSubscriptions: [ + { + productId: 'dev.hyo.martie.premium', + transactionId: 'transaction-1', + purchaseToken: 'token-1', + isActive: true, + transactionDate: Date.now(), + }, + ], + fetchProducts: mockFetchProducts, + getAvailablePurchases: mockGetAvailablePurchases, + getActiveSubscriptions: mockGetActiveSubscriptions, + finishTransaction: mockFinishTransaction, + }); + }); + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { + get: jest.fn(() => originalPlatform), + configurable: true, + }); + }); + + it('loads and renders purchase status', async () => { + const {getByText} = render(); + + expect(getByText('Store Connection: ✅ Connected')).toBeDefined(); + expect(getByText('🔄 Active Subscriptions')).toBeDefined(); + expect(getByText('💰 Available Purchases')).toBeDefined(); + await waitFor(() => { + expect(mockFetchProducts).toHaveBeenCalled(); + expect(mockGetAvailablePurchases).toHaveBeenCalled(); + expect(mockGetActiveSubscriptions).toHaveBeenCalled(); + }); + }); + + it('shows Vega guidance instead of opening unsupported subscription management deep links', async () => { + Object.defineProperty(Platform, 'OS', { + get: jest.fn(() => 'kepler'), + configurable: true, + }); + + const {getByText} = render(); + + await waitFor(() => { + expect(mockGetActiveSubscriptions).toHaveBeenCalled(); + }); + + fireEvent.press(getByText('🔗 Manage Subscriptions')); + + expect( + getByText(/Subscription management deep links are not exposed/), + ).toBeDefined(); + expect(ExpoIap.deepLinkToSubscriptions).not.toHaveBeenCalled(); + }); +}); diff --git a/libraries/expo-iap/example/__tests__/index.test.tsx b/libraries/expo-iap/example/__tests__/index.test.tsx index 9caf7214..20f04d9c 100644 --- a/libraries/expo-iap/example/__tests__/index.test.tsx +++ b/libraries/expo-iap/example/__tests__/index.test.tsx @@ -88,4 +88,15 @@ describe('Home Component', () => { consoleLog.mockRestore(); }); + + it('should skip storefront lookup on Vega', () => { + Object.defineProperty(Platform, 'OS', { + get: jest.fn(() => 'kepler'), + configurable: true, + }); + + const {getByText} = render(); + expect(getByText('expo-iap Examples')).toBeDefined(); + expect(ExpoIap.getStorefront).not.toHaveBeenCalled(); + }); }); diff --git a/libraries/expo-iap/example/__tests__/offer-code.test.tsx b/libraries/expo-iap/example/__tests__/offer-code.test.tsx index 05d36670..7f4f1f88 100644 --- a/libraries/expo-iap/example/__tests__/offer-code.test.tsx +++ b/libraries/expo-iap/example/__tests__/offer-code.test.tsx @@ -64,6 +64,23 @@ describe('OfferCode Component', () => { ).toBeDefined(); }); + it('should show Vega unsupported guidance without calling platform redemption APIs', () => { + Object.defineProperty(Platform, 'OS', { + get: jest.fn(() => 'kepler'), + configurable: true, + }); + + const {getByText} = render(); + + fireEvent.press(getByText('Amazon Vega IAP')); + + expect(ExpoIap.presentCodeRedemptionSheetIOS).not.toHaveBeenCalled(); + expect(ExpoIap.openRedeemOfferCodeAndroid).not.toHaveBeenCalled(); + expect( + getByText(/Offer code redemption is not supported on Amazon Vega/), + ).toBeDefined(); + }); + it('should handle redeem button press on iOS', async () => { Object.defineProperty(Platform, 'OS', { get: jest.fn(() => 'ios'), diff --git a/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx b/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx index a58085de..c57a4d56 100644 --- a/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx @@ -37,7 +37,7 @@ const mockUseIAP = { jest.mock('../../src', () => ({ useIAP: jest.fn(() => mockUseIAP), - requestPurchase: jest.fn(), + requestPurchase: jest.fn(() => Promise.resolve()), getAppTransactionIOS: jest.fn(), getStorefront: jest.fn(), })); diff --git a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx index a4949cb3..5ce6bc2a 100644 --- a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import {render, fireEvent} from '@testing-library/react-native'; +import {render, fireEvent, waitFor} from '@testing-library/react-native'; import {Alert, Platform} from 'react-native'; -import SubscriptionFlow from '../app/subscription-flow'; // Mock expo-constants jest.mock('expo-constants', () => ({ @@ -18,7 +17,7 @@ jest.spyOn(Alert, 'alert'); // Mock the functions const mockInitConnection = jest.fn().mockResolvedValue(true); const mockFetchProducts = jest.fn(); -const mockRequestPurchase = jest.fn(); +const mockRequestPurchase = jest.fn().mockResolvedValue(undefined); const mockFinishTransaction = jest.fn(); const mockGetActiveSubscriptions = jest.fn(); const mockGetAvailablePurchases = jest.fn().mockResolvedValue([]); @@ -71,6 +70,14 @@ jest.mock('../../src', () => ({ useIAP: () => mockUseIAP(), })); +const SubscriptionFlow = require('../app/subscription-flow').default; + +async function renderConnectedSubscriptionFlow() { + const result = render(); + await waitFor(() => expect(mockGetActiveSubscriptions).toHaveBeenCalled()); + return result; +} + describe('SubscriptionFlow Component', () => { beforeEach(() => { jest.clearAllMocks(); @@ -92,26 +99,26 @@ describe('SubscriptionFlow Component', () => { }); }); - it('should render without crashing', () => { - const {getByText} = render(); + it('should render without crashing', async () => { + const {getByText} = await renderConnectedSubscriptionFlow(); expect(getByText('Subscription Flow')).toBeDefined(); }); - it('should show connected status', () => { - const {getByText} = render(); + it('should show connected status', async () => { + const {getByText} = await renderConnectedSubscriptionFlow(); // Look for the text that contains "Connected" expect(getByText(/✅ Connected/)).toBeDefined(); }); - it('should display subscriptions', () => { - const {getByText} = render(); + it('should display subscriptions', async () => { + const {getByText} = await renderConnectedSubscriptionFlow(); expect(getByText('Test Subscription')).toBeDefined(); // The subscription might show different price format expect(getByText('Test Description')).toBeDefined(); }); - it('should handle subscribe button click', () => { - const {getByText} = render(); + it('should handle subscribe button click', async () => { + const {getByText} = await renderConnectedSubscriptionFlow(); const subscribeButton = getByText('Subscribe'); fireEvent.press(subscribeButton); @@ -120,12 +127,12 @@ describe('SubscriptionFlow Component', () => { expect(mockFetchProducts).toHaveBeenCalled(); }); - it('should call fetchProducts on mount', () => { - render(); + it('should call fetchProducts on mount', async () => { + await renderConnectedSubscriptionFlow(); expect(mockFetchProducts).toHaveBeenCalled(); }); - it('should display active subscriptions when available', () => { + it('should display active subscriptions when available', async () => { const activeSubscription = { productId: 'test.subscription.1', isActive: true, @@ -146,13 +153,13 @@ describe('SubscriptionFlow Component', () => { activeSubscriptions: [activeSubscription], }); - const {getByText} = render(); + const {getByText} = await renderConnectedSubscriptionFlow(); expect(getByText('Current Subscription Status')).toBeDefined(); expect(getByText('✅ Active')).toBeDefined(); expect(getByText('test.subscription.1')).toBeDefined(); }); - it('should show expiration warning for soon-to-expire subscriptions', () => { + it('should show expiration warning for soon-to-expire subscriptions', async () => { const expiringSubscription = { productId: 'test.subscription.1', isActive: true, @@ -172,12 +179,12 @@ describe('SubscriptionFlow Component', () => { activeSubscriptions: [expiringSubscription], }); - const {getByText} = render(); + const {getByText} = await renderConnectedSubscriptionFlow(); expect(getByText(/Your subscription will expire soon/)).toBeDefined(); expect(getByText(/3 days remaining/)).toBeDefined(); }); - it('should handle Android subscriptions correctly', () => { + it('should handle Android subscriptions correctly', async () => { Object.defineProperty(Platform, 'OS', { value: 'android', writable: true, @@ -201,12 +208,12 @@ describe('SubscriptionFlow Component', () => { activeSubscriptions: [androidActiveSubscription], }); - const {getByText} = render(); + const {getByText} = await renderConnectedSubscriptionFlow(); expect(getByText('⚠️ Cancelled')).toBeDefined(); expect(getByText(/Your subscription will not auto-renew/)).toBeDefined(); }); - it('should show active subscription status section', () => { + it('should show active subscription status section', async () => { mockUseIAP.mockReturnValue({ connected: true, subscriptions: [createMockSubscription()], @@ -224,7 +231,7 @@ describe('SubscriptionFlow Component', () => { ], }); - const {getByText} = render(); + const {getByText} = await renderConnectedSubscriptionFlow(); // The status section should be present when there are active subscriptions expect(getByText('Current Subscription Status')).toBeDefined(); expect(getByText('✅ Active')).toBeDefined(); @@ -287,16 +294,16 @@ describe('SubscriptionFlow Component', () => { expect(getByText('Connecting to store...')).toBeDefined(); }); - it('should display introductory offer for iOS', () => { + it('should display introductory offer for iOS', async () => { Object.defineProperty(Platform, 'OS', { value: 'ios', writable: true, }); - const {getByText} = render(); + const {getByText} = await renderConnectedSubscriptionFlow(); expect(getByText('7 day(s) free trial')).toBeDefined(); }); - it('should have subscribe button for each subscription', () => { + it('should have subscribe button for each subscription', async () => { mockUseIAP.mockReturnValue({ connected: true, subscriptions: [createMockSubscription()], @@ -308,14 +315,14 @@ describe('SubscriptionFlow Component', () => { activeSubscriptions: [], }); - const {getByText} = render(); + const {getByText} = await renderConnectedSubscriptionFlow(); const subscribeButton = getByText('Subscribe'); // Test that the button exists expect(subscribeButton).toBeDefined(); }); - it('should show check status link when no active subscriptions', () => { + it('should show check status link when no active subscriptions', async () => { mockUseIAP.mockReturnValue({ connected: true, subscriptions: [createMockSubscription()], @@ -327,7 +334,7 @@ describe('SubscriptionFlow Component', () => { activeSubscriptions: [], }); - const {getByText} = render(); + const {getByText} = await renderConnectedSubscriptionFlow(); // When there are no active subscriptions but connected, show check status link expect(getByText('Check Status')).toBeDefined(); diff --git a/libraries/expo-iap/example/__tests__/vega-runtime.test.ts b/libraries/expo-iap/example/__tests__/vega-runtime.test.ts new file mode 100644 index 00000000..c1672111 --- /dev/null +++ b/libraries/expo-iap/example/__tests__/vega-runtime.test.ts @@ -0,0 +1,64 @@ +jest.mock('expo-constants', () => ({ + __esModule: true, + default: { + expoConfig: { + extra: { + iapkitApiKey: 'test-api-key', + iapkitBaseUrl: 'http://localhost:3100', + }, + }, + }, +})); + +import { + createIapkitVerificationPayload, + getDefaultVerificationMethod, +} from '../src/utils/vegaRuntime'; +import type {Purchase} from '../../src/types'; + +describe('Vega runtime example helpers', () => { + it('uses configured IAPKit credentials for Amazon purchases', () => { + const payload = createIapkitVerificationPayload( + { + id: 'receipt-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'receipt-1', + store: 'amazon', + } as Purchase, + 'receipt-1', + ); + + expect(payload).toMatchObject({ + apiKey: 'test-api-key', + baseUrl: 'http://localhost:3100', + amazon: { + receiptId: 'receipt-1', + sandbox: true, + }, + }); + }); + + it('uses configured IAPKit credentials for non-Amazon purchases', () => { + const payload = createIapkitVerificationPayload( + { + id: 'token-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'token-1', + store: 'google', + } as Purchase, + 'token-1', + ); + + expect(payload).toMatchObject({ + apiKey: 'test-api-key', + baseUrl: 'http://localhost:3100', + google: { + purchaseToken: 'token-1', + }, + }); + }); + + it('defaults to IAPKit verification when an API key is configured', () => { + expect(getDefaultVerificationMethod()).toBe('iapkit'); + }); +}); diff --git a/libraries/expo-iap/example/__tests__/webhook-stream.test.tsx b/libraries/expo-iap/example/__tests__/webhook-stream.test.tsx new file mode 100644 index 00000000..92ab9c53 --- /dev/null +++ b/libraries/expo-iap/example/__tests__/webhook-stream.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import {fireEvent, render, waitFor} from '@testing-library/react-native'; +import WebhookStream from '../app/webhook-stream'; +import * as ExpoIap from 'expo-iap'; + +jest.mock('expo-constants', () => ({ + expoConfig: {extra: {}}, +})); + +const mockConnectWebhookStream = ExpoIap.connectWebhookStream as jest.Mock; + +describe('WebhookStream Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + (globalThis as {btoa?: (value: string) => string}).btoa = (value) => + Buffer.from(value, 'binary').toString('base64'); + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(''), + }), + ) as jest.Mock; + }); + + it('connects, renders incoming events, and triggers a test notification when configured', async () => { + mockConnectWebhookStream.mockImplementationOnce((options) => { + options.onEvent({ + id: 'event-1', + type: 'TestNotification', + source: 'google-play-real-time-developer-notifications', + platform: 'Android', + environment: 'Sandbox', + projectId: 'project-1', + occurredAt: Date.now(), + receivedAt: Date.now(), + productId: 'dev.hyo.martie.premium', + }); + return {close: jest.fn()}; + }); + + const {getByText} = render( + , + ); + + fireEvent.press(getByText('Connect')); + + await waitFor(() => { + expect(mockConnectWebhookStream).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-key', + baseUrl: 'http://localhost:8787', + }), + ); + expect(getByText('TestNotification')).toBeDefined(); + expect(getByText(/productId: dev.hyo.martie.premium/)).toBeDefined(); + }); + + fireEvent.press(getByText('Trigger test notification')); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:8787/v1/webhooks/test-key', + expect.objectContaining({ + method: 'POST', + headers: {'content-type': 'application/json'}, + }), + ); + expect(getByText('Test notification accepted (200).')).toBeDefined(); + }); + }); +}); diff --git a/libraries/expo-iap/example/amazon.config.json b/libraries/expo-iap/example/amazon.config.json new file mode 100644 index 00000000..4f1837fa --- /dev/null +++ b/libraries/expo-iap/example/amazon.config.json @@ -0,0 +1,3 @@ +{ + "debug.amazon.sandboxmode": "debug" +} diff --git a/libraries/expo-iap/example/amazon.sdktester.json b/libraries/expo-iap/example/amazon.sdktester.json new file mode 100644 index 00000000..43cd5e90 --- /dev/null +++ b/libraries/expo-iap/example/amazon.sdktester.json @@ -0,0 +1,43 @@ +{ + "dev.hyo.martie.10bulbs": { + "itemType": "CONSUMABLE", + "price": 0.99, + "title": "10 Bulbs", + "description": "A small pack of bulbs for testing consumable purchases", + "smallIconUrl": "https://openiap.dev/img/logo.png" + }, + "dev.hyo.martie.30bulbs": { + "itemType": "CONSUMABLE", + "price": 1.99, + "title": "30 Bulbs", + "description": "A larger pack of bulbs for testing consumable purchases", + "smallIconUrl": "https://openiap.dev/img/logo.png" + }, + "dev.hyo.martie.certified": { + "itemType": "ENTITLED", + "price": 4.99, + "title": "Certified", + "description": "A non-consumable entitlement for OpenIAP example testing", + "smallIconUrl": "https://openiap.dev/img/logo.png" + }, + "dev.hyo.martie.premium": { + "itemType": "SUBSCRIPTION", + "price": 4.99, + "title": "Premium Monthly", + "description": "Monthly premium access for OpenIAP example testing", + "smallIconUrl": "https://openiap.dev/img/logo.png", + "subscriptionBase": "dev.hyo.martie.premium.base", + "subscriptionParent": "dev.hyo.martie.premium.parent", + "term": "Monthly" + }, + "dev.hyo.martie.premium_year": { + "itemType": "SUBSCRIPTION", + "price": 49.99, + "title": "Premium Yearly", + "description": "Yearly premium access for OpenIAP example testing", + "smallIconUrl": "https://openiap.dev/img/logo.png", + "subscriptionBase": "dev.hyo.martie.premium.base", + "subscriptionParent": "dev.hyo.martie.premium.parent", + "term": "Yearly" + } +} diff --git a/libraries/expo-iap/example/app.config.ts b/libraries/expo-iap/example/app.config.ts index 5e731708..7f03856b 100644 --- a/libraries/expo-iap/example/app.config.ts +++ b/libraries/expo-iap/example/app.config.ts @@ -10,16 +10,23 @@ const LOCAL_OPENIAP_PATHS = { // Read library version mode from libraries-versions.jsonc const parseJsonc = (text: string) => JSON.parse(text.replace(/^\s*\/\/.*$/gm, '')); -const versionsPath = path.resolve(__dirname, '../../../libraries-versions.jsonc'); +const versionsPath = path.resolve( + __dirname, + '../../../libraries-versions.jsonc', +); const librariesVersions: Record = fs.existsSync(versionsPath) ? parseJsonc(fs.readFileSync(versionsPath, 'utf8')) : {'expo-iap': 'local'}; -const useLocalDev = !librariesVersions['expo-iap'] || librariesVersions['expo-iap'] === 'local'; +const useLocalDev = + !librariesVersions['expo-iap'] || librariesVersions['expo-iap'] === 'local'; export default ({config}: ConfigContext): ExpoConfig => { // Check if building for TV (set EXPO_TV=1 before prebuild) const isTV = process.env.EXPO_TV === '1'; - const isOnsideEnabled = false; + 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 pluginEntries: NonNullable = [ // TV config plugin (must be first for TV builds) @@ -41,7 +48,19 @@ export default ({config}: ConfigContext): ExpoConfig => { // Onside module: iOS only (alternative billing for Korea) onside: isOnsideEnabled, // Horizon module: Android only (Meta Quest/VR devices) - horizon: false, + horizon: isHorizonEnabled, + }, + amazon: { + // Fire OS: Android amazon flavor + fireOS: isFireOsEnabled, + // Vega OS: Kepler runtime target + vegaOS: isVegaEnabled, + }, + vega: { + packageId: 'dev.hyo.openiap.expo.example', + title: 'Expo IAP Example', + appName: 'ExpoIAPExample', + icon: './assets/images/icon.png', }, android: { // Horizon App ID for Meta Quest/VR devices (required when modules.horizon is true) @@ -127,7 +146,7 @@ export default ({config}: ConfigContext): ExpoConfig => { ios: { ...config.ios, supportsTablet: false, - bundleIdentifier: 'dev.hyo.martie', + bundleIdentifier: 'dev.hyo.openiap.expo.example', }, android: { ...config.android, @@ -135,13 +154,18 @@ export default ({config}: ConfigContext): ExpoConfig => { foregroundImage: './assets/images/adaptive-icon.png', backgroundColor: '#000000', }, - package: 'dev.hyo.martie', + package: 'dev.hyo.openiap.expo.example', }, plugins: pluginEntries, experiments: { ...config.experiments, typedRoutes: true, }, + extra: { + ...config.extra, + iapkitApiKey: process.env.EXPO_PUBLIC_IAPKIT_API_KEY, + iapkitBaseUrl: process.env.EXPO_PUBLIC_IAPKIT_BASE_URL, + }, }; return expoConfig; diff --git a/libraries/expo-iap/example/app/all-products.tsx b/libraries/expo-iap/example/app/all-products.tsx index 37f574fe..dcf77c1c 100644 --- a/libraries/expo-iap/example/app/all-products.tsx +++ b/libraries/expo-iap/example/app/all-products.tsx @@ -15,15 +15,14 @@ import { CONSUMABLE_PRODUCT_IDS, NON_CONSUMABLE_PRODUCT_IDS, } from '../src/utils/constants'; +import {extractErrorMessage} from '../src/utils/errorUtils'; import type {Product, ProductSubscription} from '../../src/types'; -const ALL_PRODUCT_IDS = [...PRODUCT_IDS, ...SUBSCRIPTION_PRODUCT_IDS]; - /** * All Products Example - Show All Products and Subscriptions * * Demonstrates fetching all products (both in-app and subscriptions): - * - Uses fetchProducts with 'all' type to get everything + * - Fetches in-app products and subscriptions separately * - Displays products and subscriptions as they come from the API * - Single view for all product types * @@ -64,21 +63,27 @@ function AllProducts() { Product | ProductSubscription | null >(null); const [modalVisible, setModalVisible] = useState(false); + const [loadError, setLoadError] = useState(null); const {connected, products, subscriptions, fetchProducts} = useIAP(); useEffect(() => { console.log('[AllProducts] useEffect - connected:', connected); if (connected) { - console.log('[AllProducts] Fetching all products'); + console.log('[AllProducts] Fetching product groups'); + setLoadError(null); - // Fetch all products with type 'all' - fetchProducts({skus: ALL_PRODUCT_IDS, type: 'all'}) + Promise.all([ + fetchProducts({skus: PRODUCT_IDS, type: 'in-app'}), + fetchProducts({skus: SUBSCRIPTION_PRODUCT_IDS, type: 'subs'}), + ]) .then(() => { console.log('[AllProducts] fetchProducts completed'); }) .catch((error) => { - console.error('[AllProducts] fetchProducts error:', error); + const message = extractErrorMessage(error); + console.log('[AllProducts] fetchProducts error:', message); + setLoadError(message); }); } }, [connected, fetchProducts]); @@ -192,6 +197,13 @@ function AllProducts() { + {loadError ? ( + + Product loading failed + {loadError} + + ) : null} + {/* Products List */} Products (In-App) @@ -756,6 +768,25 @@ const styles = StyleSheet.create({ color: '#666', textAlign: 'center', }, + errorBox: { + backgroundColor: '#FFF3E0', + borderColor: '#FF9800', + borderRadius: 8, + borderWidth: 1, + marginBottom: 15, + padding: 12, + }, + errorTitle: { + color: '#E65100', + fontSize: 14, + fontWeight: '700', + marginBottom: 4, + }, + errorText: { + color: '#5D4037', + fontSize: 13, + lineHeight: 18, + }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', diff --git a/libraries/expo-iap/example/app/alternative-billing.tsx b/libraries/expo-iap/example/app/alternative-billing.tsx index 4faa009e..1b96671a 100644 --- a/libraries/expo-iap/example/app/alternative-billing.tsx +++ b/libraries/expo-iap/example/app/alternative-billing.tsx @@ -61,6 +61,8 @@ const EXTERNAL_BILLING_PROGRAMS: BillingProgramAndroid[] = [ 'external-content-link', ]; +const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; + function AlternativeBillingScreen() { const [externalUrl, setExternalUrl] = useState('https://openiap.dev'); const [selectedProduct, setSelectedProduct] = useState(null); @@ -74,6 +76,7 @@ function AlternativeBillingScreen() { const [lastPurchase, setLastPurchase] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); + const isVega = isVegaOS(); // Initialize with billing program config (new API) const {connected, products, fetchProducts, finishTransaction} = useIAP({ @@ -105,13 +108,13 @@ function AlternativeBillingScreen() { }); console.log('Transaction finished'); } catch (error) { - console.warn('Failed to finish transaction:', error); + console.log('Failed to finish transaction:', error); } Alert.alert('Success', 'Purchase completed successfully!'); }, onPurchaseError: (error: PurchaseError) => { - console.error('Purchase failed:', error); + console.log('Purchase failed:', error); setIsProcessing(false); setPurchaseResult(`❌ Purchase failed: ${error.message}`); @@ -123,15 +126,15 @@ function AlternativeBillingScreen() { // Load products when connected useEffect(() => { - if (connected) { + if (connected && !isVega) { fetchProducts({skus: CONSUMABLE_PRODUCT_IDS, type: 'in-app'}).catch( (error) => { - console.error('Failed to load products:', error); + console.log('Failed to load products:', error); Alert.alert('Error', 'Failed to load products'); }, ); } - }, [connected, fetchProducts]); + }, [connected, fetchProducts, isVega]); // Reconnect with new billing program const reconnectWithProgram = useCallback( @@ -168,7 +171,7 @@ function AlternativeBillingScreen() { // Reload products await fetchProducts({skus: CONSUMABLE_PRODUCT_IDS, type: 'in-app'}); } catch (error: any) { - console.error('Reconnection error:', error); + console.log('Reconnection error:', error); setPurchaseResult(`❌ Reconnection failed: ${error.message}`); } finally { setIsReconnecting(false); @@ -210,7 +213,7 @@ function AlternativeBillingScreen() { ); } } catch (error: any) { - console.error('[iOS] Alternative billing error:', error); + console.log('[iOS] Alternative billing error:', error); setPurchaseResult(`❌ Error: ${error.message}`); Alert.alert('Error', error.message); } finally { @@ -281,7 +284,7 @@ function AlternativeBillingScreen() { 'Billing Programs API flow completed.\n\nIn production, report the token to Google Play backend within 24 hours.', ); } catch (error: any) { - console.error('[Android] Billing Programs API error:', error); + console.log('[Android] Billing Programs API error:', error); setPurchaseResult(`❌ Error: ${error.message}`); Alert.alert('Error', error.message); } finally { @@ -317,7 +320,7 @@ function AlternativeBillingScreen() { ); }) .catch((error) => { - console.error('[Android] User choice billing error:', error); + console.log('[Android] User choice billing error:', error); setPurchaseResult(`❌ Error: ${error.message}`); Alert.alert('Error', error.message); }); @@ -326,6 +329,13 @@ function AlternativeBillingScreen() { // Handle purchase based on platform and mode const handlePurchase = useCallback( (product: Product) => { + if (isVega) { + setPurchaseResult( + 'Alternative billing is not supported on Amazon Vega. Use the standard Amazon IAP Purchase Flow or Subscription Flow screens instead.', + ); + return; + } + if (Platform.OS === 'ios') { handleIOSAlternativeBillingPurchase(product); } else if (Platform.OS === 'android') { @@ -344,6 +354,7 @@ function AlternativeBillingScreen() { handleIOSAlternativeBillingPurchase, handleAndroidBillingPrograms, handleAndroidUserChoiceBilling, + isVega, ], ); @@ -356,9 +367,11 @@ function AlternativeBillingScreen() { Alternative Billing - {Platform.OS === 'ios' - ? 'External purchase links (iOS 16.0+)' - : 'Google Play alternative billing'} + {isVega + ? 'Not supported on Amazon Vega' + : Platform.OS === 'ios' + ? 'External purchase links (iOS 16.0+)' + : 'Google Play alternative billing'} @@ -366,7 +379,20 @@ function AlternativeBillingScreen() { {/* Info Card */} ℹ️ How It Works - {Platform.OS === 'ios' ? ( + {isVega ? ( + <> + + • Vega OS uses Amazon Appstore IAP through the Vega JavaScript + runtime{'\n'}• Google Play alternative billing and iOS external + purchase links do not apply{'\n'}• Test Amazon IAP in the + Purchase Flow and Subscription Flow screens + + + ⚠️ Alternative billing APIs are intentionally unsupported on + Amazon Vega. + + + ) : Platform.OS === 'ios' ? ( <> • Enter your external purchase URL{'\n'}• Tap Purchase on any @@ -478,7 +504,11 @@ function AlternativeBillingScreen() { > {connected ? '✅ Connected' : '❌ Disconnected'} - {Platform.OS === 'android' ? ( + {isVega ? ( + + Current mode: Amazon Vega standard IAP + + ) : Platform.OS === 'android' ? ( Current program: {billingProgram.toUpperCase().replace(/-/g, '_')} @@ -548,18 +578,23 @@ function AlternativeBillingScreen() { handlePurchase(selectedProduct)} - disabled={isProcessing || !connected} + disabled={isProcessing || !connected || isVega} > {isProcessing ? 'Processing...' - : Platform.OS === 'ios' - ? '🛒 Buy (External URL)' - : androidBillingFlow === 'billing-programs' - ? '🛒 Buy (Billing Programs)' - : '🛒 Buy (User Choice Billing)'} + : isVega + ? 'Not supported on Vega' + : Platform.OS === 'ios' + ? '🛒 Buy (External URL)' + : androidBillingFlow === 'billing-programs' + ? '🛒 Buy (Billing Programs)' + : '🛒 Buy (User Choice Billing)'} diff --git a/libraries/expo-iap/example/app/available-purchases.tsx b/libraries/expo-iap/example/app/available-purchases.tsx index b366cc1c..7a08f053 100644 --- a/libraries/expo-iap/example/app/available-purchases.tsx +++ b/libraries/expo-iap/example/app/available-purchases.tsx @@ -19,6 +19,8 @@ import type {PurchaseError} from '../../src/utils/errorMapping'; import PurchaseDetails from '../src/components/PurchaseDetails'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; +const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; + export default function AvailablePurchases() { const [loading, setLoading] = useState(false); const [isCheckingStatus, setIsCheckingStatus] = useState(false); @@ -30,6 +32,10 @@ export default function AvailablePurchases() { ); const [purchaseDetailsVisible, setPurchaseDetailsVisible] = useState(false); const [storefront, setStorefront] = useState(''); + const [subscriptionLinkMessage, setSubscriptionLinkMessage] = useState< + string | null + >(null); + const isVega = isVegaOS(); // Deduplicate purchases by productId, keeping the most recent transaction const deduplicatePurchases = (purchases: Purchase[]): Purchase[] => { @@ -81,7 +87,7 @@ export default function AvailablePurchases() { checkSubscriptionStatus(); }, onPurchaseError: (error: PurchaseError) => { - console.error('[AVAILABLE-PURCHASES] Purchase failed:', error); + console.log('[AVAILABLE-PURCHASES] Purchase failed:', error); Alert.alert('Purchase Failed', error.message); }, }); @@ -105,11 +111,11 @@ export default function AvailablePurchases() { 'items', ); } catch (error) { - console.error( + console.log( '[AVAILABLE-PURCHASES] Error checking subscription status:', error, ); - console.warn( + console.log( '[AVAILABLE-PURCHASES] Subscription status check failed, but existing state preserved', ); } finally { @@ -139,7 +145,7 @@ export default function AvailablePurchases() { '[AVAILABLE-PURCHASES] Available purchases and active subscriptions loaded', ); } catch (error) { - console.error('[AVAILABLE-PURCHASES] Error loading purchases:', error); + console.log('[AVAILABLE-PURCHASES] Error loading purchases:', error); Alert.alert('Error', 'Failed to load purchase data'); } finally { setLoading(false); @@ -153,13 +159,20 @@ export default function AvailablePurchases() { setStorefront(code || ''); Alert.alert('Storefront', code || '(empty)'); } catch (e: any) { - console.warn('Failed to get storefront:', e?.message); + console.log('Failed to get storefront:', e?.message); Alert.alert('Storefront', 'Failed to get storefront'); } }; const handleOpenSubscriptions = async () => { try { + if (isVega) { + setSubscriptionLinkMessage( + 'Subscription management deep links are not exposed through the Amazon Vega OpenIAP adapter. Use this screen to inspect active subscriptions and purchase history.', + ); + return; + } + if (Platform.OS === 'android') { // Use first known subscription id if available, else fall back to constant const sku = subscriptions[0]?.id ?? SUBSCRIPTION_PRODUCT_IDS[0]; @@ -174,7 +187,9 @@ export default function AvailablePurchases() { await deepLinkToSubscriptions({}); } } catch (e: any) { - Alert.alert('Deep Link Error', e?.message || 'Failed to open'); + const message = e?.message || 'Failed to open'; + setSubscriptionLinkMessage(message); + Alert.alert('Deep Link Error', message); } }; @@ -184,8 +199,14 @@ export default function AvailablePurchases() { console.log( '[AVAILABLE-PURCHASES] Connected to store, loading subscription products...', ); - // Request products first - this is event-based, not promise-based - fetchProducts({skus: SUBSCRIPTION_PRODUCT_IDS, type: 'subs'}); + fetchProducts({skus: SUBSCRIPTION_PRODUCT_IDS, type: 'subs'}).catch( + (error) => { + console.log( + '[AVAILABLE-PURCHASES] Failed to load subscription products:', + error, + ); + }, + ); console.log( '[AVAILABLE-PURCHASES] Product loading request sent - waiting for results...', ); @@ -196,7 +217,7 @@ export default function AvailablePurchases() { ); Promise.all([getAvailablePurchases(), getActiveSubscriptions()]).catch( (error) => { - console.warn( + console.log( '[AVAILABLE-PURCHASES] Failed to load purchase data:', error, ); @@ -396,6 +417,9 @@ export default function AvailablePurchases() { > 🔗 Manage Subscriptions + {subscriptionLinkMessage ? ( + {subscriptionLinkMessage} + ) : null} {/* Subscription Details Modal */} Purchase Token - {selectedSubscription.purchaseToken} + + {selectedSubscription.purchaseToken} + )} @@ -688,6 +714,12 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, + helperText: { + color: '#666', + fontSize: 13, + lineHeight: 18, + marginTop: 10, + }, // Modal styles modalOverlay: { flex: 1, diff --git a/libraries/expo-iap/example/app/index.tsx b/libraries/expo-iap/example/app/index.tsx index bec75aeb..8f12283c 100644 --- a/libraries/expo-iap/example/app/index.tsx +++ b/libraries/expo-iap/example/app/index.tsx @@ -5,6 +5,7 @@ import { Text, TouchableOpacity, View, + Platform, } from 'react-native'; import {Link} from 'expo-router'; import {getStorefront} from 'expo-iap'; @@ -85,8 +86,13 @@ const MENU_ITEMS: MenuItem[] = [ */ export default function Home() { const [storefront, setStorefront] = useState(null); + const [focusedIndex, setFocusedIndex] = useState(0); useEffect(() => { + if ((Platform.OS as string) === 'kepler') { + return; + } + getStorefront() .then((code) => { setStorefront(code); @@ -107,10 +113,18 @@ export default function Home() { ); - const renderItem = (item: MenuItem) => { + const renderItem = (item: MenuItem, index: number) => { return ( - + setFocusedIndex(index)} + style={[ + styles.menuItem, + focusedIndex === index && styles.menuItemFocused, + ]} + > @@ -130,9 +144,7 @@ export default function Home() { {renderHeader()} - - {MENU_ITEMS.map(renderItem)} - + {MENU_ITEMS.map(renderItem)} ); @@ -175,7 +187,7 @@ const styles = StyleSheet.create({ backgroundColor: '#FFFFFF', borderColor: '#E2E8F0', borderRadius: 8, - borderWidth: 1, + borderWidth: 2, flexDirection: 'row', minHeight: 84, paddingHorizontal: 16, @@ -189,6 +201,9 @@ const styles = StyleSheet.create({ shadowRadius: 6, elevation: 3, }, + menuItemFocused: { + borderColor: '#2563EB', + }, iconContainer: { alignItems: 'center', borderRadius: 8, diff --git a/libraries/expo-iap/example/app/offer-code.tsx b/libraries/expo-iap/example/app/offer-code.tsx index dff6bdae..4b02262e 100644 --- a/libraries/expo-iap/example/app/offer-code.tsx +++ b/libraries/expo-iap/example/app/offer-code.tsx @@ -22,8 +22,23 @@ import { * functionality for both iOS and Android platforms. */ +const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; + // Platform-specific content helpers const getPlatformContent = () => { + if (isVegaOS()) { + return { + buttonText: 'Amazon Vega IAP', + buttonSubtext: 'Offer code redemption is unavailable', + howItWorks: + '• Vega OS uses Amazon App Tester or Amazon Appstore catalog data\n• iOS offer codes and Google Play promo codes do not apply\n• Use the Purchase Flow or Subscription Flow screens to test Amazon IAP', + platformNote: + 'Vega OS does not expose an OpenIAP offer-code redemption API.', + testingInfo: + '• Configure amazon.sdktester.json for sandbox products\n• Enable sandbox mode with amazon.config.json\n• Test purchases through the Amazon App Tester flow', + }; + } + const isIOS = Platform.OS === 'ios'; return { buttonText: isIOS ? '🎁 Redeem Offer Code' : '🎁 Open Play Store', @@ -43,10 +58,19 @@ const getPlatformContent = () => { export default function OfferCodeScreen() { const {connected} = useIAP(); const [isRedeeming, setIsRedeeming] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); const platformContent = getPlatformContent(); const isIOS = Platform.OS === 'ios'; + const isVega = isVegaOS(); const handleRedeemCode = async () => { + if (isVega) { + setStatusMessage( + 'Offer code redemption is not supported on Amazon Vega. Use Amazon App Tester catalog entries and the standard purchase or subscription flows instead.', + ); + return; + } + if (!connected) { Alert.alert('Not Connected', 'Please wait for store connection'); return; @@ -73,7 +97,7 @@ export default function OfferCodeScreen() { ); } } catch (error) { - console.error('Error redeeming code:', error); + console.log('Error redeeming code:', error); Alert.alert( 'Error', `Failed to redeem code: ${ @@ -119,11 +143,17 @@ export default function OfferCodeScreen() { - Platform: {isIOS ? 'ios' : 'android'} + Platform: {isVega ? 'Vega OS' : isIOS ? 'ios' : 'android'} {platformContent.platformNote} + {statusMessage ? ( + + {statusMessage} + + ) : null} + Testing Offer Codes {platformContent.testingInfo} @@ -285,4 +315,17 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#555', }, + statusMessageBox: { + backgroundColor: '#fff7ed', + borderColor: '#fed7aa', + borderRadius: 12, + borderWidth: 1, + marginBottom: 20, + padding: 16, + }, + statusMessageText: { + color: '#9a3412', + fontSize: 14, + lineHeight: 20, + }, }); diff --git a/libraries/expo-iap/example/app/purchase-flow.tsx b/libraries/expo-iap/example/app/purchase-flow.tsx index a34935dd..61d6b132 100644 --- a/libraries/expo-iap/example/app/purchase-flow.tsx +++ b/libraries/expo-iap/example/app/purchase-flow.tsx @@ -28,16 +28,31 @@ import type { Purchase, VerifyPurchaseWithProviderProps, } from '../../src/types'; +import {ErrorCode} from '../../src/types'; import type {PurchaseError} from '../../src/utils/errorMapping'; import PurchaseDetails from '../src/components/PurchaseDetails'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; import {extractErrorMessage} from '../src/utils/errorUtils'; +import {useVegaTvSelection} from '../src/hooks/useVegaTvSelection'; +import { + createIapkitVerificationPayload, + getDefaultVerificationMethod, + getPurchaseCleanupKey, + showNativeAlert, +} from '../src/utils/vegaRuntime'; type VerificationMethod = 'ignore' | 'local' | 'iapkit'; const CONSUMABLE_PRODUCT_ID_SET = new Set(CONSUMABLE_PRODUCT_IDS); const NON_CONSUMABLE_PRODUCT_ID_SET = new Set(NON_CONSUMABLE_PRODUCT_IDS); +function isPurchaseFlowProduct(productId: string): boolean { + return ( + CONSUMABLE_PRODUCT_ID_SET.has(productId) || + NON_CONSUMABLE_PRODUCT_ID_SET.has(productId) + ); +} + const deduplicatePurchases = (purchases: Purchase[]): Purchase[] => { const uniquePurchases = new Map(); @@ -163,6 +178,21 @@ function PurchaseFlow({ [onPurchase], ); + const { + selectedIndex: tvSelectedProductIndex, + setSelectedIndex: setTvSelectedProductIndex, + } = useVegaTvSelection({ + itemCount: visibleProducts.length, + isItemDisabled: () => isProcessing, + onSelect: (index) => { + const selectedProduct = visibleProducts[index]; + if (selectedProduct) { + handlePurchase(selectedProduct.id); + } + }, + suppressSelection: isProcessing || Boolean(purchaseResult), + }); + const handleCopyResult = async () => { if (purchaseResult) { await Clipboard.setStringAsync(purchaseResult); @@ -199,7 +229,7 @@ function PurchaseFlow({ Alert.alert('App Transaction', 'No app transaction found'); } } catch (error) { - console.error('Failed to get app transaction:', error); + console.log('Failed to get app transaction:', error); Alert.alert('Error', 'Failed to get app transaction'); } }; @@ -309,7 +339,7 @@ function PurchaseFlow({ : 'Loading products...'} - {visibleProducts.map((product) => ( + {visibleProducts.map((product, index) => ( {product.title} @@ -336,11 +366,16 @@ function PurchaseFlow({ handlePurchase(product.id)} + onFocus={() => setTvSelectedProductIndex(index)} disabled={isProcessing} > @@ -348,6 +383,7 @@ function PurchaseFlow({ handleShowDetails(product)} > @@ -737,7 +773,7 @@ function PurchaseFlowContainer() { const [storefrontError, setStorefrontError] = useState(null); const [storefrontLoading, setStorefrontLoading] = useState(false); const [verificationMethod, setVerificationMethod] = - useState('ignore'); + useState(getDefaultVerificationMethod()); const verificationMethodRef = useRef(verificationMethod); const isHandlingPurchaseRef = useRef(false); @@ -747,6 +783,7 @@ function PurchaseFlowContainer() { }, [verificationMethod]); const {showActionSheetWithOptions} = useActionSheet(); + const cleanupPurchaseKeysRef = useRef(new Set()); // ============================================================ // Step 1: initConnection @@ -772,7 +809,6 @@ function PurchaseFlowContainer() { console.log('[PurchaseFlow] Already handling purchase, skipping'); return; } - isHandlingPurchaseRef.current = true; const {purchaseToken: tokenToMask, ...rest} = purchase; @@ -782,27 +818,25 @@ function PurchaseFlowContainer() { }; console.log('Purchase successful:', masked); console.log('[PurchaseFlow] purchaseState:', purchase.purchaseState); + const productId = purchase.productId ?? ''; + if (!isPurchaseFlowProduct(productId)) { + console.log('[PurchaseFlow] ignoring non-purchase-flow product:', { + productId, + }); + return; + } + + isHandlingPurchaseRef.current = true; setLastPurchase(purchase); setIsProcessing(false); setPurchaseResult( - `Purchase completed successfully (state: ${purchase.purchaseState}).`, + `Purchase received (state: ${purchase.purchaseState}). Finishing transaction...`, ); - const productId = purchase.productId ?? ''; const isConsumablePurchase = CONSUMABLE_PRODUCT_ID_SET.has(productId); - if (!isConsumablePurchase && productId) { - if (NON_CONSUMABLE_PRODUCT_ID_SET.has(productId)) { - console.log( - '[PurchaseFlow] Non-consumable purchase recorded:', - productId, - ); - } else { - console.warn( - '[PurchaseFlow] Purchase for product not listed in constants:', - productId, - ); - } + if (!isConsumablePurchase) { + console.log('[PurchaseFlow] Non-consumable purchase recorded:', productId); } // ------------------------------------------------------------ @@ -829,7 +863,7 @@ function PurchaseFlowContainer() { apple: {sku: productId}, google: { sku: productId, - packageName: 'dev.anthropic.iapexample', + packageName: 'dev.hyo.openiap.expo.example', purchaseToken: purchase.purchaseToken ?? '', // Required for production accessToken: '', // Requires server-issued OAuth token }, @@ -839,9 +873,6 @@ function PurchaseFlowContainer() { // Option 2: IAPKit verification (server-based) } else if (currentVerificationMethod === 'iapkit') { console.log('[PurchaseFlow] Verifying with IAPKit...'); - // Note: apiKey is automatically injected from config plugin (iapkitApiKey) - // No need to manually pass it - expo-iap reads it from Constants.expoConfig.extra.iapkitApiKey - console.log( '[PurchaseFlow] purchase.purchaseToken:', purchase.purchaseToken && @@ -852,7 +883,7 @@ function PurchaseFlowContainer() { const jwsOrToken = purchase.purchaseToken ?? ''; if (!jwsOrToken) { - console.warn( + console.log( '[PurchaseFlow] No purchaseToken/JWS available for verification', ); throw new Error( @@ -860,36 +891,28 @@ function PurchaseFlowContainer() { ); } - // apiKey is auto-filled from config plugin - no need to specify it + const iapkitPayload = createIapkitVerificationPayload( + purchase, + jwsOrToken, + ); const verifyRequest: VerifyPurchaseWithProviderProps = { provider: 'iapkit', - iapkit: { - apple: { - jws: jwsOrToken, - }, - google: { - purchaseToken: jwsOrToken, - }, - }, + iapkit: iapkitPayload, + }; + const iapkitLogPayload = { + ...iapkitPayload, + ...(iapkitPayload.apiKey ? {apiKey: '***hidden***'} : {}), }; console.log( '[PurchaseFlow] Sending IAPKit verification request:', JSON.stringify( - { - provider: verifyRequest.provider, - iapkit: { - ...(Platform.OS === 'ios' - ? {apple: {jws: jwsOrToken}} - : { - google: { - purchaseToken: jwsOrToken, - }, - }), + { + provider: verifyRequest.provider, + iapkit: iapkitLogPayload, }, - }, - null, - 2, + null, + 2, ), ); @@ -902,7 +925,7 @@ function PurchaseFlowContainer() { const statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; const stateText = iapkitResult.state || 'unknown'; - Alert.alert( + showNativeAlert( `${statusEmoji} IAPKit Verification`, `Valid: ${iapkitResult.isValid}\nState: ${stateText}\nStore: ${ iapkitResult.store || 'unknown' @@ -911,8 +934,8 @@ function PurchaseFlowContainer() { } } } catch (error) { - console.warn('[PurchaseFlow] Verification failed:', error); - Alert.alert( + console.log('[PurchaseFlow] Verification failed:', error); + showNativeAlert( 'Verification Failed', `Purchase verification failed: ${extractErrorMessage(error)}`, ); @@ -925,13 +948,22 @@ function PurchaseFlowContainer() { // Step 6: finish transaction // IMPORTANT: Must call finishTransaction to complete the purchase // ------------------------------------------------------------ + let didFinishTransaction = false; try { await finishTransaction({ purchase, isConsumable: isConsumablePurchase, }); + didFinishTransaction = true; + setPurchaseResult( + `Purchase completed and finished successfully (state: ${purchase.purchaseState}).`, + ); } catch (error) { - console.warn('[PurchaseFlow] finishTransaction failed:', error); + const message = extractErrorMessage(error); + setPurchaseResult( + `Purchase completed, but finishTransaction failed: ${message}`, + ); + console.log('[PurchaseFlow] finishTransaction failed:', error); } // ------------------------------------------------------------ @@ -942,13 +974,15 @@ function PurchaseFlowContainer() { await getAvailablePurchases(); console.log('[PurchaseFlow] Available purchases refreshed'); } catch (error) { - console.warn( + console.log( '[PurchaseFlow] Failed to refresh available purchases:', error, ); } - Alert.alert('Success', 'Purchase completed successfully!'); + if (didFinishTransaction) { + showNativeAlert('Success', 'Purchase completed successfully!'); + } // Reset handling state after all operations complete isHandlingPurchaseRef.current = false; @@ -957,8 +991,14 @@ function PurchaseFlowContainer() { // Step 2: subscribeEvent - onPurchaseError callback // ------------------------------------------------------------ onPurchaseError: (error: PurchaseError) => { - console.error('Purchase failed:', error); + console.log('Purchase failed:', error.message); setIsProcessing(false); + if (error.code === ErrorCode.UserCancelled) { + setPurchaseResult('Purchase cancelled by user'); + isHandlingPurchaseRef.current = false; + return; + } + setPurchaseResult(`Purchase failed: ${error.message}`); isHandlingPurchaseRef.current = false; }, @@ -977,7 +1017,9 @@ function PurchaseFlowContainer() { console.log('[PurchaseFlow] fetchProducts completed'); }) .catch((error) => { - console.error('[PurchaseFlow] fetchProducts error:', error); + const message = extractErrorMessage(error); + console.log('[PurchaseFlow] fetchProducts error:', message); + setPurchaseResult(`Product loading failed: ${message}`); }); getAvailablePurchases() @@ -985,15 +1027,54 @@ function PurchaseFlowContainer() { console.log('[PurchaseFlow] getAvailablePurchases completed'); }) .catch((error) => { - console.warn('[PurchaseFlow] getAvailablePurchases error:', error); + console.log('[PurchaseFlow] getAvailablePurchases error:', error); }); } else if (!connected) { didFetchRef.current = false; + cleanupPurchaseKeysRef.current.clear(); console.log('[PurchaseFlow] Not fetching products - not connected'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [connected]); + useEffect(() => { + if (!connected || availablePurchases.length === 0) return; + + for (const purchase of availablePurchases) { + const productId = purchase.productId ?? ''; + if (!isPurchaseFlowProduct(productId)) { + console.log( + '[PurchaseFlow] skipping cleanup for non-purchase-flow product:', + {productId}, + ); + continue; + } + + const cleanupKey = getPurchaseCleanupKey(purchase); + if (cleanupPurchaseKeysRef.current.has(cleanupKey)) continue; + cleanupPurchaseKeysRef.current.add(cleanupKey); + + const isConsumablePurchase = CONSUMABLE_PRODUCT_ID_SET.has(productId); + finishTransaction({ + purchase, + isConsumable: isConsumablePurchase, + }) + .then(() => { + console.log('[PurchaseFlow] cleaned up available purchase:', { + productId, + isConsumable: isConsumablePurchase, + }); + }) + .catch((error) => { + cleanupPurchaseKeysRef.current.delete(cleanupKey); + console.log( + '[PurchaseFlow] available purchase cleanup failed:', + error, + ); + }); + } + }, [availablePurchases, connected, finishTransaction]); + const handleRefreshAvailablePurchases = useCallback(async () => { if (refreshingAvailablePurchases) { return; @@ -1003,7 +1084,7 @@ function PurchaseFlowContainer() { try { await getAvailablePurchases(); } catch (error) { - console.warn( + console.log( '[PurchaseFlow] Failed to refresh available purchases manually:', error, ); @@ -1024,13 +1105,6 @@ function PurchaseFlowContainer() { setIsProcessing(true); setPurchaseResult('Processing purchase...'); - if (typeof requestPurchase !== 'function') { - console.warn('[PurchaseFlow] requestPurchase missing (test/mock env)'); - setIsProcessing(false); - setPurchaseResult('Cannot start purchase in test/mock environment.'); - return; - } - void requestPurchase({ request: { // Option 1: Apple purchase request @@ -1049,6 +1123,18 @@ function PurchaseFlowContainer() { // }, }, type: 'in-app', + }).catch((error: PurchaseError) => { + console.log('requestPurchase failed:', { + code: error.code, + message: error.message, + }); + setIsProcessing(false); + if (error.code === ErrorCode.UserCancelled) { + setPurchaseResult('Purchase cancelled by user'); + return; + } + + setPurchaseResult(`Purchase failed: ${error.message}`); }); }, [setIsProcessing, setPurchaseResult], @@ -1089,7 +1175,7 @@ function PurchaseFlowContainer() { const code = await getStorefront(); setStorefront(code ?? ''); } catch (error) { - console.warn('[PurchaseFlow] getStorefront error:', error); + console.log('[PurchaseFlow] getStorefront error:', error); setStorefrontError( error instanceof Error ? error.message : 'Failed to load storefront', ); @@ -1308,6 +1394,10 @@ const styles = StyleSheet.create({ fontWeight: '600', fontSize: 14, }, + tvFocusedButton: { + borderColor: '#0F172A', + borderWidth: 3, + }, detailsButton: { paddingVertical: 10, paddingHorizontal: 20, diff --git a/libraries/expo-iap/example/app/subscription-flow.tsx b/libraries/expo-iap/example/app/subscription-flow.tsx index 582ad03b..d003281a 100644 --- a/libraries/expo-iap/example/app/subscription-flow.tsx +++ b/libraries/expo-iap/example/app/subscription-flow.tsx @@ -26,10 +26,18 @@ import type { Purchase, VerifyPurchaseWithProviderProps, } from '../../src/types'; +import {ErrorCode} from '../../src/types'; import type {PurchaseError} from '../../src/utils/errorMapping'; import PurchaseDetails from '../src/components/PurchaseDetails'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; import {extractErrorMessage} from '../src/utils/errorUtils'; +import {useVegaTvSelection} from '../src/hooks/useVegaTvSelection'; +import { + createIapkitVerificationPayload, + getDefaultVerificationMethod, + getPurchaseCleanupKey, + showNativeAlert, +} from '../src/utils/vegaRuntime'; type VerificationMethod = 'ignore' | 'local' | 'iapkit'; @@ -43,6 +51,13 @@ const getSubscriptionTier = (productId: string): number => { return TIER_MAP[productId] ?? 0; }; +function isSubscriptionFlowProduct(productId: string): boolean { + return SUBSCRIPTION_PRODUCT_IDS.some( + (subscriptionId) => + productId === subscriptionId || productId.startsWith(`${subscriptionId}.`), + ); +} + /** * Subscription Flow Example - Subscription Products * @@ -336,6 +351,84 @@ function SubscriptionFlow({ ], ); + const getSubscriptionButtonState = useCallback( + (subscription: ProductSubscription) => { + const isSubscribed = activeSubscriptions.some( + (sub) => sub.productId === subscription.id, + ); + const isPending = isPendingUpgrade(subscription.id); + const upgradeInfo = getUpgradeInfo(subscription.id); + const isProductCancelled = isCancelled(subscription.id); + + let buttonText = 'Subscribe'; + let buttonStyles = [styles.subscribeButton]; + let buttonDisabled = isProcessing || !connected; + + if (isProcessing) { + buttonText = 'Processing...'; + buttonDisabled = true; + } else if (isPending) { + buttonText = '⏳ Scheduled'; + buttonStyles = [styles.pendingButton]; + buttonDisabled = true; + } else if (isSubscribed && !isProductCancelled) { + buttonText = '✅ Subscribed'; + buttonStyles = [styles.subscribedButton]; + buttonDisabled = true; + } else if (isSubscribed && isProductCancelled) { + buttonText = '🔄 Reactivate'; + buttonStyles = [styles.reactivateButton]; + buttonDisabled = false; + } else if (upgradeInfo.canUpgrade) { + buttonText = '⬆️ Upgrade'; + buttonStyles = [styles.upgradeButton]; + buttonDisabled = false; + } else if (upgradeInfo.isDowngrade) { + buttonText = '⬇️ Downgrade'; + buttonStyles = [styles.downgradeButton]; + buttonDisabled = false; + } + + return { + buttonDisabled, + buttonStyles, + buttonText, + isPending, + isProductCancelled, + isSubscribed, + upgradeInfo, + }; + }, + [ + activeSubscriptions, + connected, + getUpgradeInfo, + isCancelled, + isPendingUpgrade, + isProcessing, + ], + ); + + const { + selectedIndex: tvSelectedSubscriptionIndex, + setSelectedIndex: setTvSelectedSubscriptionIndex, + } = useVegaTvSelection({ + itemCount: subscriptions.length, + isItemDisabled: (index) => { + const subscription = subscriptions[index]; + return ( + !subscription || getSubscriptionButtonState(subscription).buttonDisabled + ); + }, + onSelect: (index) => { + const subscription = subscriptions[index]; + if (subscription) { + handleSubscription(subscription.id); + } + }, + suppressSelection: isProcessing || Boolean(purchaseResult), + }); + const retryLoadSubscriptions = useCallback(() => { onRetryLoadSubscriptions(); }, [onRetryLoadSubscriptions]); @@ -1181,43 +1274,16 @@ function SubscriptionFlow({ {!connected ? ( ) : subscriptions.length > 0 ? ( - subscriptions.map((subscription) => { - const isSubscribed = activeSubscriptions.some( - (sub) => sub.productId === subscription.id, - ); - const isPending = isPendingUpgrade(subscription.id); - const upgradeInfo = getUpgradeInfo(subscription.id); - const isProductCancelled = isCancelled(subscription.id); - - // Determine button state and text - let buttonText = 'Subscribe'; - let buttonStyles = [styles.subscribeButton]; - let buttonDisabled = isProcessing || !connected; - - if (isProcessing) { - buttonText = 'Processing...'; - buttonDisabled = true; - } else if (isPending) { - buttonText = '⏳ Scheduled'; - buttonStyles = [styles.pendingButton]; - buttonDisabled = true; - } else if (isSubscribed && !isProductCancelled) { - buttonText = '✅ Subscribed'; - buttonStyles = [styles.subscribedButton]; - buttonDisabled = true; - } else if (isSubscribed && isProductCancelled) { - buttonText = '🔄 Reactivate'; - buttonStyles = [styles.reactivateButton]; - buttonDisabled = false; - } else if (upgradeInfo.canUpgrade) { - buttonText = '⬆️ Upgrade'; - buttonStyles = [styles.upgradeButton]; - buttonDisabled = false; - } else if (upgradeInfo.isDowngrade) { - buttonText = '⬇️ Downgrade'; - buttonStyles = [styles.downgradeButton]; - buttonDisabled = false; - } + subscriptions.map((subscription, index) => { + const { + buttonDisabled, + buttonStyles, + buttonText, + isPending, + isProductCancelled, + isSubscribed, + upgradeInfo, + } = getSubscriptionButtonState(subscription); return ( @@ -1261,17 +1327,23 @@ function SubscriptionFlow({ handleSubscriptionPress(subscription)} > ℹ️ handleSubscription(subscription.id)} + onFocus={() => setTvSelectedSubscriptionIndex(index)} disabled={buttonDisabled} > (null); const [verificationMethod, setVerificationMethod] = - useState('ignore'); + useState(getDefaultVerificationMethod()); const verificationMethodRef = useRef(verificationMethod); // Keep ref in sync with state @@ -1478,6 +1550,7 @@ function SubscriptionFlowContainer() { const isHandlingPurchaseRef = useRef(false); const isCheckingStatusRef = useRef(false); const didFetchSubsRef = useRef(false); + const cleanupPurchaseKeysRef = useRef(new Set()); const resetHandlingState = useCallback(() => { isHandlingPurchaseRef.current = false; @@ -1490,8 +1563,10 @@ function SubscriptionFlowContainer() { const { connected, subscriptions, + availablePurchases, fetchProducts, finishTransaction, + getAvailablePurchases, getActiveSubscriptions, activeSubscriptions, verifyPurchase, @@ -1514,6 +1589,15 @@ function SubscriptionFlowContainer() { '[SubscriptionFlow] Current verificationMethod ref:', verificationMethodRef.current, ); + + const productId = purchase.productId ?? ''; + if (!isSubscriptionFlowProduct(productId)) { + console.log('[SubscriptionFlow] ignoring non-subscription product:', { + productId, + }); + return; + } + setLastPurchase(purchase); if (isHandlingPurchaseRef.current) { @@ -1525,13 +1609,21 @@ function SubscriptionFlowContainer() { } isHandlingPurchaseRef.current = true; - setIsProcessing(false); let isPurchased = false; let isRestoration = false; const purchasePlatform = (purchase.platform ?? '') .toString() .toLowerCase(); + const purchaseStore = (purchase as Purchase & {store?: string | null}) + .store; + const normalizedPurchaseStore = purchaseStore?.toLowerCase() ?? ''; + const hasAndroidPurchaseIdentity = Boolean( + purchase.purchaseToken || + purchase.id || + purchase.transactionId || + purchase.productId, + ); if (Platform.OS === 'ios' && purchasePlatform === 'ios') { const hasValidToken = !!( @@ -1569,21 +1661,29 @@ function SubscriptionFlowContainer() { ? purchase.transactionReasonIOS : undefined, ); - } else if (Platform.OS === 'android' && purchasePlatform === 'android') { - isPurchased = true; + } else if ( + Platform.OS === 'android' || + purchasePlatform === 'android' || + normalizedPurchaseStore === 'amazon' + ) { + isPurchased = hasAndroidPurchaseIdentity; isRestoration = false; console.log('Android Purchase Analysis:'); + console.log(' platform:', purchasePlatform || Platform.OS); + console.log(' store:', normalizedPurchaseStore || 'unknown'); + console.log(' hasAndroidPurchaseIdentity:', hasAndroidPurchaseIdentity); console.log(' isPurchased:', isPurchased); console.log(' isRestoration:', isRestoration); } if (!isPurchased) { - console.warn( + console.log( 'Purchase callback received but purchase validation failed', ); setPurchaseResult('Purchase validation failed.'); - Alert.alert( + setIsProcessing(false); + showNativeAlert( 'Purchase Issue', 'Purchase could not be validated. Please try again.', ); @@ -1601,7 +1701,9 @@ function SubscriptionFlowContainer() { console.log( '[SubscriptionFlow] This is a restoration, skipping verification', ); - setPurchaseResult('Subscription restored successfully.'); + setPurchaseResult( + 'Subscription restored; finishing transaction...', + ); // Step 6: finish transaction (restoration) try { @@ -1609,8 +1711,14 @@ function SubscriptionFlowContainer() { purchase, isConsumable: false, }); + setPurchaseResult('Subscription restored and finished successfully.'); } catch (error) { - console.warn('finishTransaction failed during restoration:', error); + setPurchaseResult( + `Subscription restored, but finishTransaction failed: ${extractErrorMessage( + error, + )}`, + ); + console.log('finishTransaction failed during restoration:', error); } console.log('✅ Subscription restoration completed'); @@ -1619,19 +1727,18 @@ function SubscriptionFlowContainer() { try { await getActiveSubscriptions(); } catch (error) { - console.warn('Failed to refresh status:', error); + console.log('Failed to refresh status:', error); } resetHandlingState(); + setIsProcessing(false); return; } console.log( '[SubscriptionFlow] Not a restoration, proceeding to verification check', ); - setPurchaseResult('Subscription activated successfully.'); - - const productId = purchase.productId; + setPurchaseResult('Subscription received; finishing transaction...'); // ------------------------------------------------------------ // Step 4: verifyPurchase - 3 methods available @@ -1660,7 +1767,7 @@ function SubscriptionFlowContainer() { apple: {sku: productId}, google: { sku: productId, - packageName: 'dev.anthropic.iapexample', + packageName: 'dev.hyo.openiap.expo.example', purchaseToken: purchase.purchaseToken ?? '', accessToken: '', // ⚠️ Requires server-issued OAuth token isSub: true, @@ -1673,9 +1780,6 @@ function SubscriptionFlowContainer() { ); } else if (currentVerificationMethod === 'iapkit') { console.log('[SubscriptionFlow] Verifying with IAPKit...'); - // Note: apiKey is automatically injected from config plugin (iapkitApiKey) - // No need to manually pass it - expo-iap reads it from Constants.expoConfig.extra.iapkitApiKey - console.log( '[SubscriptionFlow] purchase.purchaseToken:', purchase.purchaseToken && @@ -1686,7 +1790,7 @@ function SubscriptionFlowContainer() { const jwsOrToken = purchase.purchaseToken ?? ''; if (!jwsOrToken) { - console.warn( + console.log( '[SubscriptionFlow] No purchaseToken/JWS available for verification', ); throw new Error( @@ -1694,36 +1798,28 @@ function SubscriptionFlowContainer() { ); } - // apiKey is auto-filled from config plugin - no need to specify it + const iapkitPayload = createIapkitVerificationPayload( + purchase, + jwsOrToken, + ); const verifyRequest: VerifyPurchaseWithProviderProps = { provider: 'iapkit', - iapkit: { - apple: { - jws: jwsOrToken, - }, - google: { - purchaseToken: jwsOrToken, - }, - }, + iapkit: iapkitPayload, + }; + const iapkitLogPayload = { + ...iapkitPayload, + ...(iapkitPayload.apiKey ? {apiKey: '***hidden***'} : {}), }; console.log( '[SubscriptionFlow] Sending IAPKit verification request:', JSON.stringify( - { - provider: verifyRequest.provider, - iapkit: { - ...(Platform.OS === 'ios' - ? {apple: {jws: jwsOrToken}} - : { - google: { - purchaseToken: jwsOrToken, - }, - }), + { + provider: verifyRequest.provider, + iapkit: iapkitLogPayload, }, - }, - null, - 2, + null, + 2, ), ); @@ -1739,7 +1835,7 @@ function SubscriptionFlowContainer() { const statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; const stateText = iapkitResult.state || 'unknown'; - Alert.alert( + showNativeAlert( `${statusEmoji} IAPKit Verification`, `Valid: ${iapkitResult.isValid}\nState: ${stateText}\nStore: ${ iapkitResult.store || 'unknown' @@ -1748,8 +1844,8 @@ function SubscriptionFlowContainer() { } } } catch (error) { - console.warn('[SubscriptionFlow] Verification failed:', error); - Alert.alert( + console.log('[SubscriptionFlow] Verification failed:', error); + showNativeAlert( 'Verification Failed', `Purchase verification failed: ${extractErrorMessage(error)}`, ); @@ -1763,17 +1859,27 @@ function SubscriptionFlowContainer() { // IMPORTANT: Must call finishTransaction to complete the purchase // Subscriptions are NOT consumable (isConsumable: false) // ------------------------------------------------------------ + let didFinishTransaction = false; try { await finishTransaction({ purchase, isConsumable: false, }); + didFinishTransaction = true; + setPurchaseResult('Subscription activated and finished successfully.'); } catch (error) { - console.warn('finishTransaction failed (new purchase):', error); + setPurchaseResult( + `Subscription activated, but finishTransaction failed: ${extractErrorMessage( + error, + )}`, + ); + console.log('finishTransaction failed (new purchase):', error); } - Alert.alert('Success', 'New subscription activated successfully!'); - console.log('✅ New subscription purchase completed'); + if (didFinishTransaction) { + showNativeAlert('Success', 'New subscription activated successfully!'); + console.log('✅ New subscription purchase completed'); + } // ------------------------------------------------------------ // Step 5: grant entitlement @@ -1783,7 +1889,7 @@ function SubscriptionFlowContainer() { try { await getActiveSubscriptions(); } catch (error) { - console.warn('Failed to refresh status:', error); + console.log('Failed to refresh status:', error); } resetHandlingState(); @@ -1794,9 +1900,14 @@ function SubscriptionFlowContainer() { // Handle purchase failures (user cancelled, payment failed, etc.) // ------------------------------------------------------------ onPurchaseError: (error: PurchaseError) => { - console.error('Subscription failed:', error); + console.log('Subscription failed:', error.message); setIsProcessing(false); resetHandlingState(); + if (error.code === ErrorCode.UserCancelled) { + setPurchaseResult('Subscription cancelled by user'); + return; + } + setPurchaseResult(`Subscription failed: ${error.message}`); }, }); @@ -1821,10 +1932,13 @@ function SubscriptionFlowContainer() { isCheckingStatusRef.current = true; setIsCheckingStatus(true); try { - getActiveSubscriptions(); + await getActiveSubscriptions(); } catch (error) { - console.error('Error checking subscription status:', error); - console.warn( + console.log( + 'Error checking subscription status:', + extractErrorMessage(error), + ); + console.log( 'Subscription status check failed, but existing state preserved', ); } finally { @@ -1842,14 +1956,58 @@ function SubscriptionFlowContainer() { if (connected && !didFetchSubsRef.current) { didFetchSubsRef.current = true; console.log('Connected to store, loading subscription products...'); - fetchProducts({skus: subscriptionIds, type: 'subs'}); + fetchProducts({skus: subscriptionIds, type: 'subs'}).catch((error) => { + const message = extractErrorMessage(error); + console.log('[SubscriptionFlow] fetchProducts error:', message); + setPurchaseResult(`Subscription loading failed: ${message}`); + }); + getAvailablePurchases().catch((error) => { + console.log('[SubscriptionFlow] getAvailablePurchases error:', error); + }); console.log('Product loading request sent - waiting for results...'); } else if (!connected) { didFetchSubsRef.current = false; + cleanupPurchaseKeysRef.current.clear(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [connected]); + useEffect(() => { + if (!connected || availablePurchases.length === 0) return; + + for (const purchase of availablePurchases) { + const productId = purchase.productId ?? ''; + if (!isSubscriptionFlowProduct(productId)) { + console.log( + '[SubscriptionFlow] skipping cleanup for non-subscription product:', + {productId}, + ); + continue; + } + + const cleanupKey = getPurchaseCleanupKey(purchase); + if (cleanupPurchaseKeysRef.current.has(cleanupKey)) continue; + cleanupPurchaseKeysRef.current.add(cleanupKey); + + finishTransaction({ + purchase, + isConsumable: false, + }) + .then(() => { + console.log('[SubscriptionFlow] cleaned up available purchase:', { + productId, + }); + }) + .catch((error) => { + cleanupPurchaseKeysRef.current.delete(cleanupKey); + console.log( + '[SubscriptionFlow] available purchase cleanup failed:', + error, + ); + }); + } + }, [availablePurchases, connected, finishTransaction]); + // ============================================================ // On App Launch - Check Existing Subscriptions // ============================================================ @@ -1944,15 +2102,6 @@ function SubscriptionFlowContainer() { ) : []; - if (typeof requestPurchase !== 'function') { - console.warn( - '[SubscriptionFlow] requestPurchase missing (test/mock env)', - ); - setIsProcessing(false); - setPurchaseResult('Cannot start purchase in test/mock environment.'); - return; - } - void requestPurchase({ request: { // Apple subscription request @@ -1967,13 +2116,31 @@ function SubscriptionFlowContainer() { }, }, type: 'subs', + }).catch((error: PurchaseError) => { + console.log('requestPurchase failed:', { + code: error.code, + message: error.message, + }); + setIsProcessing(false); + if (error.code === ErrorCode.UserCancelled) { + setPurchaseResult('Subscription cancelled by user'); + return; + } + + setPurchaseResult(`Subscription failed: ${error.message}`); }); }, [activeSubscriptions, subscriptions], ); const handleRetryLoadSubscriptions = useCallback(() => { - fetchProducts({skus: SUBSCRIPTION_PRODUCT_IDS, type: 'subs'}); + fetchProducts({skus: SUBSCRIPTION_PRODUCT_IDS, type: 'subs'}).catch( + (error) => { + const message = extractErrorMessage(error); + console.log('[SubscriptionFlow] retry fetchProducts error:', message); + setPurchaseResult(`Subscription loading failed: ${message}`); + }, + ); }, [fetchProducts]); const handleManageSubscriptions = useCallback(async () => { @@ -1983,7 +2150,7 @@ function SubscriptionFlowContainer() { const openedNative = await showManageSubscriptionsIOS() .then(() => true) .catch((error) => { - console.warn( + console.log( '[SubscriptionFlow] showManageSubscriptionsIOS failed, falling back to deep link', error, ); @@ -2008,7 +2175,10 @@ function SubscriptionFlowContainer() { ); } } catch (error) { - console.error('Failed to open subscription management:', error); + console.log( + 'Failed to open subscription management:', + extractErrorMessage(error), + ); Alert.alert('Error', 'Failed to open subscription management'); } }, [handleRefreshStatus, subscriptions]); @@ -2238,6 +2408,10 @@ const styles = StyleSheet.create({ fontWeight: '600', fontSize: 16, }, + tvFocusedButton: { + borderColor: '#0F172A', + borderWidth: 3, + }, disabledButton: { opacity: 0.5, }, diff --git a/libraries/expo-iap/example/app/webhook-stream.tsx b/libraries/expo-iap/example/app/webhook-stream.tsx index 7fbf29be..69ed0ca9 100644 --- a/libraries/expo-iap/example/app/webhook-stream.tsx +++ b/libraries/expo-iap/example/app/webhook-stream.tsx @@ -26,6 +26,11 @@ function base64EncodeUtf8(input: string): string { return btoa(unescape(encodeURIComponent(input))); } +type WebhookStreamScreenProps = { + apiKey?: string; + baseUrl?: string; +}; + /** * Webhook Stream Demo * @@ -35,12 +40,21 @@ function base64EncodeUtf8(input: string): string { * so the round-trip can be exercised without going through Apple ASN v2 or * Google RTDN. */ -export default function WebhookStreamScreen() { - const apiKey: string | undefined = - (Constants.expoConfig?.extra as {iapkitApiKey?: string} | undefined) - ?.iapkitApiKey ?? process.env.EXPO_PUBLIC_IAPKIT_API_KEY; - const baseUrl = - process.env.EXPO_PUBLIC_IAPKIT_BASE_URL ?? 'https://kit.openiap.dev'; +export default function WebhookStreamScreen({ + apiKey: apiKeyOverride, + baseUrl: baseUrlOverride, +}: WebhookStreamScreenProps = {}) { + const expoExtra = Constants.expoConfig?.extra as + | {iapkitApiKey?: string; iapkitBaseUrl?: string} + | undefined; + const configuredApiKey: string | undefined = + expoExtra?.iapkitApiKey ?? process.env.EXPO_PUBLIC_IAPKIT_API_KEY; + const configuredBaseUrl = + expoExtra?.iapkitBaseUrl ?? + process.env.EXPO_PUBLIC_IAPKIT_BASE_URL ?? + 'https://kit.openiap.dev'; + const apiKey = apiKeyOverride ?? configuredApiKey; + const baseUrl = baseUrlOverride ?? configuredBaseUrl; const [events, setEvents] = useState([]); const [status, setStatus] = useState< diff --git a/libraries/expo-iap/example/babel.config.js b/libraries/expo-iap/example/babel.config.js index 9d89e131..742ea4a0 100644 --- a/libraries/expo-iap/example/babel.config.js +++ b/libraries/expo-iap/example/babel.config.js @@ -1,5 +1,41 @@ module.exports = function (api) { - api.cache(true); + const isVega = process.env.EXPO_IAP_VEGA === '1'; + api.cache.using(() => (isVega ? 'vega' : 'expo')); + + if (isVega) { + const path = require('path'); + + return { + presets: [ + ['module:metro-react-native-babel-preset'], + 'module:@amazon-devices/kepler-module-resolver-preset', + ], + plugins: [ + [ + 'module-resolver', + { + alias: { + '^react-native$': path.resolve( + __dirname, + 'node_modules', + '@amazon-devices', + 'react-native-kepler', + 'index', + ), + '^react-native/(.+)': path.resolve( + __dirname, + 'node_modules', + '@amazon-devices', + 'react-native-kepler', + '\\1', + ), + }, + }, + ], + ], + }; + } + return { presets: ['babel-preset-expo'], }; diff --git a/libraries/expo-iap/example/bun.lock b/libraries/expo-iap/example/bun.lock index 171578af..1b1f1b69 100644 --- a/libraries/expo-iap/example/bun.lock +++ b/libraries/expo-iap/example/bun.lock @@ -34,6 +34,8 @@ }, "devDependencies": { "@babel/core": "^7.25.2", + "@react-native-community/cli": "11.3.2", + "@react-native/metro-config": "^0.72.6", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-hooks": "^8.0.1", "@testing-library/react-native": "^12.4.5", @@ -41,8 +43,10 @@ "@types/react": "~19.1.10", "@types/react-test-renderer": "^18.3.0", "babel-jest": "^29.7.0", + "babel-plugin-module-resolver": "^5.0.2", "jest": "^29.2.1", "jest-expo": "~54.0.12", + "metro-react-native-babel-preset": "~0.76.9", "react-test-renderer": "19.1.0", "typescript": "~5.9.2", }, @@ -71,6 +75,8 @@ "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.5", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "debug": "^4.4.1", "lodash.debounce": "^4.0.8", "resolve": "^1.22.10" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg=="], + "@babel/helper-environment-visitor": ["@babel/helper-environment-visitor@7.24.7", "", { "dependencies": { "@babel/types": "^7.24.7" } }, "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ=="], + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA=="], @@ -103,10 +109,24 @@ "@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], + "@babel/plugin-proposal-async-generator-functions": ["@babel/plugin-proposal-async-generator-functions@7.20.7", "", { "dependencies": { "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-remap-async-to-generator": "^7.18.9", "@babel/plugin-syntax-async-generators": "^7.8.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA=="], + + "@babel/plugin-proposal-class-properties": ["@babel/plugin-proposal-class-properties@7.18.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ=="], + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.28.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-decorators": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg=="], "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw=="], + "@babel/plugin-proposal-nullish-coalescing-operator": ["@babel/plugin-proposal-nullish-coalescing-operator@7.18.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA=="], + + "@babel/plugin-proposal-numeric-separator": ["@babel/plugin-proposal-numeric-separator@7.18.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-numeric-separator": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q=="], + + "@babel/plugin-proposal-object-rest-spread": ["@babel/plugin-proposal-object-rest-spread@7.20.7", "", { "dependencies": { "@babel/compat-data": "^7.20.5", "@babel/helper-compilation-targets": "^7.20.7", "@babel/helper-plugin-utils": "^7.20.2", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-transform-parameters": "^7.20.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg=="], + + "@babel/plugin-proposal-optional-catch-binding": ["@babel/plugin-proposal-optional-catch-binding@7.18.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw=="], + + "@babel/plugin-proposal-optional-chaining": ["@babel/plugin-proposal-optional-chaining@7.21.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA=="], + "@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="], "@babel/plugin-syntax-bigint": ["@babel/plugin-syntax-bigint@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg=="], @@ -155,6 +175,8 @@ "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.27.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-remap-async-to-generator": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA=="], + "@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA=="], + "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q=="], "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="], @@ -179,6 +201,8 @@ "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw=="], + "@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg=="], + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.27.1", "", { "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw=="], "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng=="], @@ -189,6 +213,8 @@ "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.28.0", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.0", "@babel/plugin-transform-parameters": "^7.27.7", "@babel/traverse": "^7.28.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA=="], + "@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA=="], + "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q=="], "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="], @@ -199,6 +225,8 @@ "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ=="], + "@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA=="], + "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA=="], "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/types": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw=="], @@ -301,6 +329,10 @@ "@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="], + "@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="], + + "@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -399,6 +431,30 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@react-native-community/cli": ["@react-native-community/cli@11.3.2", "", { "dependencies": { "@react-native-community/cli-clean": "11.3.2", "@react-native-community/cli-config": "11.3.2", "@react-native-community/cli-debugger-ui": "11.3.2", "@react-native-community/cli-doctor": "11.3.2", "@react-native-community/cli-hermes": "11.3.2", "@react-native-community/cli-plugin-metro": "11.3.2", "@react-native-community/cli-server-api": "11.3.2", "@react-native-community/cli-tools": "11.3.2", "@react-native-community/cli-types": "11.3.2", "chalk": "^4.1.2", "commander": "^9.4.1", "execa": "^5.0.0", "find-up": "^4.1.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.0", "semver": "^6.3.0" }, "bin": { "react-native": "build/bin.js" } }, "sha512-riyMvro6HH2NLUhcnjUrOFwi2IHb6/GOC1WKf7GvGH1L4KnIo/jP4Sk9QV+pROg1Gd9btrCTnyY7WbWuPWJJ3w=="], + + "@react-native-community/cli-clean": ["@react-native-community/cli-clean@11.3.2", "", { "dependencies": { "@react-native-community/cli-tools": "11.3.2", "chalk": "^4.1.2", "execa": "^5.0.0", "prompts": "^2.4.0" } }, "sha512-OIKeP8fYtaa9qw4bpf1m3WJDWx4GvcxTYkyycH5SDu+pZjYWNix7XtKhwoL3Ol2NJLWxdY4LnmQG1yy8OGeIRw=="], + + "@react-native-community/cli-config": ["@react-native-community/cli-config@11.3.2", "", { "dependencies": { "@react-native-community/cli-tools": "11.3.2", "chalk": "^4.1.2", "cosmiconfig": "^5.1.0", "deepmerge": "^4.3.0", "glob": "^7.1.3", "joi": "^17.2.1" } }, "sha512-hMAIR3QTjzDUmOSsbroXeNkZAcf8lpOGo1fj8CwJNfjuVho1RZZSlHKp8G7FU2sgqZmb8jWN+9tFvCRACXVYoQ=="], + + "@react-native-community/cli-debugger-ui": ["@react-native-community/cli-debugger-ui@11.3.2", "", { "dependencies": { "serve-static": "^1.13.1" } }, "sha512-pAKBcjrNl0iJoi42Ekqn3UH/QZ48pxfAIhsKkc3WmBqYetdByobhkYWfchHq3j9bilgiaBquPQaKzkg69kQw3w=="], + + "@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@11.3.2", "", { "dependencies": { "@react-native-community/cli-config": "11.3.2", "@react-native-community/cli-platform-android": "11.3.2", "@react-native-community/cli-platform-ios": "11.3.2", "@react-native-community/cli-tools": "11.3.2", "chalk": "^4.1.2", "command-exists": "^1.2.8", "envinfo": "^7.7.2", "execa": "^5.0.0", "hermes-profile-transformer": "^0.0.6", "ip": "^1.1.5", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "prompts": "^2.4.0", "semver": "^6.3.0", "strip-ansi": "^5.2.0", "sudo-prompt": "^9.0.0", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-tvmAQzz+qOJwtBCVEdhHweMeGaoHauGn3ef4tsHyqlDc0fF1K5No9DGXSESlLHpsopg066sIHWGq0PmAIeChiA=="], + + "@react-native-community/cli-hermes": ["@react-native-community/cli-hermes@11.3.2", "", { "dependencies": { "@react-native-community/cli-platform-android": "11.3.2", "@react-native-community/cli-tools": "11.3.2", "chalk": "^4.1.2", "hermes-profile-transformer": "^0.0.6", "ip": "^1.1.5" } }, "sha512-IfzdYTjxu+BFEvweY9TXpLkOmWq0sxK8PTN+u0BduiT9cJRvcO0CxjOpLHAabVrSJc6o+7aLfEvogBmdN53Xfg=="], + + "@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@11.3.2", "", { "dependencies": { "@react-native-community/cli-tools": "11.3.2", "chalk": "^4.1.2", "execa": "^5.0.0", "glob": "^7.1.3", "logkitty": "^0.7.1" } }, "sha512-NKxyBP0/gwL4/tNWrkevFSjeb7Dw2SByNfE9wFXBaAvZHxbxxJUjZOTOW3ueOXEpgOMU7IYYOiSOz2M10IRQ2A=="], + + "@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@11.3.2", "", { "dependencies": { "@react-native-community/cli-tools": "11.3.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-xml-parser": "^4.0.12", "glob": "^7.1.3", "ora": "^5.4.1" } }, "sha512-XPrfsI7dNY3f9crsKDDRIss+GHYX/snuYfMrjg4ZBHpYB5JdLepO8FJ5bFz+/s9KXDm045ijo8QFcIf3XJR0YQ=="], + + "@react-native-community/cli-plugin-metro": ["@react-native-community/cli-plugin-metro@11.3.2", "", { "dependencies": { "@react-native-community/cli-server-api": "11.3.2", "@react-native-community/cli-tools": "11.3.2", "chalk": "^4.1.2", "execa": "^5.0.0", "metro": "0.76.5", "metro-config": "0.76.5", "metro-core": "0.76.5", "metro-react-native-babel-transformer": "0.76.5", "metro-resolver": "0.76.5", "metro-runtime": "0.76.5", "readline": "^1.3.0" } }, "sha512-r1rZYCFfxZWIiUlukjMcDlxfCtm+QNYu+vFyVfE9yvN9gaNPBAi9029eVzkRkFuJ8Rxwr67HnYEAdGYLWQ1uIw=="], + + "@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@11.3.2", "", { "dependencies": { "@react-native-community/cli-debugger-ui": "11.3.2", "@react-native-community/cli-tools": "11.3.2", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "pretty-format": "^26.6.2", "serve-static": "^1.13.1", "ws": "^7.5.1" } }, "sha512-6rMb37HYWOdmiMGCxsttHDLIP7KmcJjWvzTJzb2tm9P5FoMvSSmSOn981MuP835Lk1U+IdjVcwtsA247Im4mkg=="], + + "@react-native-community/cli-tools": ["@react-native-community/cli-tools@11.3.2", "", { "dependencies": { "appdirsjs": "^1.2.4", "chalk": "^4.1.2", "find-up": "^5.0.0", "mime": "^2.4.1", "node-fetch": "^2.6.0", "open": "^6.2.0", "ora": "^5.4.1", "semver": "^6.3.0", "shell-quote": "^1.7.3" } }, "sha512-rAnFPzRITeEhBLwC73ucvWsYjsGyotSOI4c+k8t9wUqcIk1Q+RFnuWozGc13msOPdESvBHt2MPJBwXrtKgKn1g=="], + + "@react-native-community/cli-types": ["@react-native-community/cli-types@11.3.2", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-jba1Z1YgC4JIHPADSqpl4ATsrJaOja1zlQCbH/yE8McHRjVBzeYGeHIvG5jw7iU5cw6FFifH5vvr23JPGk8oyw=="], + "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="], "@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-v77jJvzH2jzMj3G8pthdaRjiUhmdQ3S/OGiTX45Tn1J+whLaPOEkVRCel9xPHhrTPIEwrOOwGNiAFN/s1hzWZA=="], @@ -419,7 +475,9 @@ "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.81.5", "", {}, "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg=="], - "@react-native/js-polyfills": ["@react-native/js-polyfills@0.81.5", "", {}, "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w=="], + "@react-native/js-polyfills": ["@react-native/js-polyfills@0.72.1", "", {}, "sha512-cRPZh2rBswFnGt5X5EUEPs0r+pAsXxYsifv/fgy9ZLQokuT52bPH+9xjDR+7TafRua5CttGW83wP4TntRcWNDA=="], + + "@react-native/metro-config": ["@react-native/metro-config@0.72.12", "", { "dependencies": { "@react-native/js-polyfills": "^0.72.1", "metro-config": "^0.76.9", "metro-react-native-babel-transformer": "^0.76.9", "metro-runtime": "^0.76.9" } }, "sha512-6NC5nr70oV8gH5vTz0yVYig6TGn97NfE58DdYottuOGPEODZf9Jpb7gdLs6Rqj5ryFDsKVPU3NsFmXKBJwEgXQ=="], "@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.4", "", {}, "sha512-9nRRHO1H+tcFqjb9gAM105Urtgcanbta2tuqCVY0NATHeFPDEAB7gPyiLxCHKMi1NbhP6TH0kxgSWXKZl1cyRg=="], @@ -435,6 +493,12 @@ "@react-navigation/routers": ["@react-navigation/routers@7.4.1", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-42mZrMzQ0LfKxUb5OHIurYrPYyRsXFLolucILrvm21f0O40Sw0Ufh1bnn/jRqnxZZu7wvpUGIGYM8nS9zVE1Aw=="], + "@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="], + + "@sideway/formula": ["@sideway/formula@3.0.1", "", {}, "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="], + + "@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="], + "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], @@ -567,6 +631,8 @@ "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + "ansi-fragments": ["ansi-fragments@0.2.1", "", { "dependencies": { "colorette": "^1.0.7", "slice-ansi": "^2.0.0", "strip-ansi": "^5.0.0" } }, "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -575,6 +641,8 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "appdirsjs": ["appdirsjs@1.2.7", "", {}, "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -583,6 +651,10 @@ "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "astral-regex": ["astral-regex@1.0.0", "", {}, "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-limiter": ["async-limiter@1.0.1", "", {}, "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -593,6 +665,8 @@ "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@29.6.3", "", { "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" } }, "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg=="], + "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.3", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-h8h6H71ZvdLJZxZrYkaeR30BojTaV7O9GfqacY14SNj5CNB8ocL9tydNzTC0JrnNN7vY3eJhwCmkDj7tuEUaqQ=="], + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.14", "", { "dependencies": { "@babel/compat-data": "^7.27.7", "@babel/helper-define-polyfill-provider": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg=="], "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], @@ -605,12 +679,16 @@ "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.29.1", "", { "dependencies": { "hermes-parser": "0.29.1" } }, "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA=="], + "babel-plugin-syntax-trailing-function-commas": ["babel-plugin-syntax-trailing-function-commas@7.0.0-beta.0", "", {}, "sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ=="], + "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.1.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw=="], "babel-preset-expo": ["babel-preset-expo@54.0.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.81.4", "babel-plugin-react-compiler": "^19.1.0-rc.2", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo"] }, "sha512-zC6g96Mbf1bofnCI8yI0VKAp8/ER/gpfTsWOpQvStbHU+E4jFZ294n3unW8Hf6nNP4NoeNq9Zc6Prp0vwhxbow=="], + "babel-preset-fbjs": ["babel-preset-fbjs@3.4.0", "", { "dependencies": { "@babel/plugin-proposal-class-properties": "^7.0.0", "@babel/plugin-proposal-object-rest-spread": "^7.0.0", "@babel/plugin-syntax-class-properties": "^7.0.0", "@babel/plugin-syntax-flow": "^7.0.0", "@babel/plugin-syntax-jsx": "^7.0.0", "@babel/plugin-syntax-object-rest-spread": "^7.0.0", "@babel/plugin-transform-arrow-functions": "^7.0.0", "@babel/plugin-transform-block-scoped-functions": "^7.0.0", "@babel/plugin-transform-block-scoping": "^7.0.0", "@babel/plugin-transform-classes": "^7.0.0", "@babel/plugin-transform-computed-properties": "^7.0.0", "@babel/plugin-transform-destructuring": "^7.0.0", "@babel/plugin-transform-flow-strip-types": "^7.0.0", "@babel/plugin-transform-for-of": "^7.0.0", "@babel/plugin-transform-function-name": "^7.0.0", "@babel/plugin-transform-literals": "^7.0.0", "@babel/plugin-transform-member-expression-literals": "^7.0.0", "@babel/plugin-transform-modules-commonjs": "^7.0.0", "@babel/plugin-transform-object-super": "^7.0.0", "@babel/plugin-transform-parameters": "^7.0.0", "@babel/plugin-transform-property-literals": "^7.0.0", "@babel/plugin-transform-react-display-name": "^7.0.0", "@babel/plugin-transform-react-jsx": "^7.0.0", "@babel/plugin-transform-shorthand-properties": "^7.0.0", "@babel/plugin-transform-spread": "^7.0.0", "@babel/plugin-transform-template-literals": "^7.0.0", "babel-plugin-syntax-trailing-function-commas": "^7.0.0-beta.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow=="], + "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -621,6 +699,8 @@ "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], @@ -647,7 +727,7 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], @@ -667,7 +747,7 @@ "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], - "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -689,9 +769,13 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], + + "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], @@ -705,6 +789,8 @@ "core-js-compat": ["core-js-compat@3.44.0", "", { "dependencies": { "browserslist": "^4.25.1" } }, "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cosmiconfig": ["cosmiconfig@5.2.1", "", { "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", "js-yaml": "^3.13.1", "parse-json": "^4.0.0" } }, "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA=="], "create-jest": ["create-jest@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" }, "bin": { "create-jest": "bin/create-jest.js" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="], @@ -721,8 +807,12 @@ "data-urls": ["data-urls@3.0.2", "", { "dependencies": { "abab": "^2.0.6", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0" } }, "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ=="], + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], @@ -739,6 +829,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "denodeify": ["denodeify@1.2.1", "", {}, "sha512-KNTihKNmQENUZeKu5fzfpzRqR5S2VMp4gl9RFHiWzj9DfvYQPMJ6XHKNaQxaGCXwPk6y9yme3aUoaiAe+KX+vg=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], @@ -777,10 +869,14 @@ "env-editor": ["env-editor@0.4.2", "", {}, "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA=="], + "envinfo": ["envinfo@7.21.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow=="], + "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + "errorhandler": ["errorhandler@1.5.2", "", { "dependencies": { "accepts": "~1.3.8", "escape-html": "~1.0.3" } }, "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -867,6 +963,8 @@ "fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], + "fast-xml-parser": ["fast-xml-parser@4.5.6", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A=="], + "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -875,6 +973,8 @@ "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], @@ -889,6 +989,8 @@ "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -911,7 +1013,7 @@ "getenv": ["getenv@1.0.0", "", {}, "sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], @@ -931,9 +1033,11 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + "hermes-estree": ["hermes-estree@0.12.0", "", {}, "sha512-+e8xR6SCen0wyAKrMT3UD0ZCCLymKhRgjEB5sS28rKiFir/fXgLoeRilRUssFCILmGHb+OvHDUlhxs0+IEyvQw=="], + + "hermes-parser": ["hermes-parser@0.12.0", "", { "dependencies": { "hermes-estree": "0.12.0" } }, "sha512-d4PHnwq6SnDLhYl3LHNHvOg7nQ6rcI7QVil418REYksv0Mh3cEkHDcuhGxNQ3vgnLSLl4QSvDrFCwQNYdpWlzw=="], - "hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + "hermes-profile-transformer": ["hermes-profile-transformer@0.0.6", "", { "dependencies": { "source-map": "^0.7.3" } }, "sha512-cnN7bQUm65UWOy6cbGcCcZ3rpwW8Q/j4OP5aWRhEry4Z2t2aR1cjrbp0BS+KiBN0smvP1caBgAuxutvyvJILzQ=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], @@ -975,6 +1079,8 @@ "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "ip": ["ip@1.1.9", "", {}, "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ=="], + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], @@ -987,13 +1093,19 @@ "is-generator-fn": ["is-generator-fn@2.1.0", "", {}, "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ=="], + "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + + "is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -1067,10 +1179,12 @@ "jest-watcher": ["jest-watcher@29.7.0", "", { "dependencies": { "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", "emittery": "^0.13.1", "jest-util": "^29.7.0", "string-length": "^4.0.1" } }, "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g=="], - "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="], + "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], @@ -1089,6 +1203,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "lan-network": ["lan-network@0.1.7", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ=="], @@ -1131,7 +1247,9 @@ "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], - "log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + + "logkitty": ["logkitty@0.7.1", "", { "dependencies": { "ansi-fragments": "^0.2.1", "dayjs": "^1.8.15", "yargs": "^15.1.0" }, "bin": { "logkitty": "bin/logkitty.js" } }, "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -1149,25 +1267,33 @@ "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], - "metro": ["metro@0.83.1", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.1", "metro-cache": "0.83.1", "metro-cache-key": "0.83.1", "metro-config": "0.83.1", "metro-core": "0.83.1", "metro-file-map": "0.83.1", "metro-resolver": "0.83.1", "metro-runtime": "0.83.1", "metro-source-map": "0.83.1", "metro-symbolicate": "0.83.1", "metro-transform-plugins": "0.83.1", "metro-transform-worker": "0.83.1", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-UGKepmTxoGD4HkQV8YWvpvwef7fUujNtTgG4Ygf7m/M0qjvb9VuDmAsEU+UdriRX7F61pnVK/opz89hjKlYTXA=="], + "metro": ["metro@0.76.5", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/parser": "^7.20.0", "@babel/template": "^7.0.0", "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", "accepts": "^1.3.7", "async": "^3.2.2", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^2.2.0", "denodeify": "^1.2.1", "error-stack-parser": "^2.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.8.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^27.2.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.76.5", "metro-cache": "0.76.5", "metro-cache-key": "0.76.5", "metro-config": "0.76.5", "metro-core": "0.76.5", "metro-file-map": "0.76.5", "metro-inspector-proxy": "0.76.5", "metro-minify-terser": "0.76.5", "metro-minify-uglify": "0.76.5", "metro-react-native-babel-preset": "0.76.5", "metro-resolver": "0.76.5", "metro-runtime": "0.76.5", "metro-source-map": "0.76.5", "metro-symbolicate": "0.76.5", "metro-transform-plugins": "0.76.5", "metro-transform-worker": "0.76.5", "mime-types": "^2.1.27", "node-fetch": "^2.2.0", "nullthrows": "^1.1.1", "rimraf": "^3.0.2", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "strip-ansi": "^6.0.0", "throat": "^5.0.0", "ws": "^7.5.1", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-aEQiqNFibfx4ajUXm7Xatsv43r/UQ0xE53T3XqgZBzsxhF235tf1cl8t0giawi0RbLtDS+Fu4kg2bVBKDYFy7A=="], "metro-babel-transformer": ["metro-babel-transformer@0.83.1", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.29.1", "nullthrows": "^1.1.1" } }, "sha512-r3xAD3964E8dwDBaZNSO2aIIvWXjIK80uO2xo0/pi3WI8XWT9h5SCjtGWtMtE5PRWw+t20TN0q1WMRsjvhC1rQ=="], - "metro-cache": ["metro-cache@0.83.1", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.1" } }, "sha512-7N/Ad1PHa1YMWDNiyynTPq34Op2qIE68NWryGEQ4TSE3Zy6a8GpsYnEEZE4Qi6aHgsE+yZHKkRczeBgxhnFIxQ=="], + "metro-cache": ["metro-cache@0.76.9", "", { "dependencies": { "metro-core": "0.76.9", "rimraf": "^3.0.2" } }, "sha512-W6QFEU5AJG1gH4Ltv8S2IvhmEhSDYnbPafyj5fGR3YLysdykj+olKv9B0V+YQXtcLGyY5CqpXLYUx595GdiKzA=="], "metro-cache-key": ["metro-cache-key@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-ZUs+GD5CNeDLxx5UUWmfg26IL+Dnbryd+TLqTlZnDEgehkIa11kUSvgF92OFfJhONeXzV4rZDRGNXoo6JT+8Gg=="], - "metro-config": ["metro-config@0.83.1", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.1", "metro-cache": "0.83.1", "metro-core": "0.83.1", "metro-runtime": "0.83.1" } }, "sha512-HJhpZx3wyOkux/jeF1o7akFJzZFdbn6Zf7UQqWrvp7gqFqNulQ8Mju09raBgPmmSxKDl4LbbNeigkX0/nKY1QA=="], + "metro-config": ["metro-config@0.76.9", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "jest-validate": "^29.2.1", "metro": "0.76.9", "metro-cache": "0.76.9", "metro-core": "0.76.9", "metro-runtime": "0.76.9" } }, "sha512-oYyJ16PY3rprsfoi80L+gDJhFJqsKI3Pob5LKQbJpvL+gGr8qfZe1eQzYp5Xxxk9DOHKBV1xD94NB8GdT/DA8Q=="], - "metro-core": ["metro-core@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.1" } }, "sha512-uVL1eAJcMFd2o2Q7dsbpg8COaxjZBBGaXqO2OHnivpCdfanraVL8dPmY6It9ZeqWLOihUKZ2yHW4b6soVCzH/Q=="], + "metro-core": ["metro-core@0.76.5", "", { "dependencies": { "lodash.throttle": "^4.1.1", "metro-resolver": "0.76.5" } }, "sha512-yJvIe8a3sAG92U7+E7Bw6m4lae9RB180fp9iQZFBqY437Ilv4nE6PR8EWB6d8c4yt9fXIL1Hc+KyQv7OPFx/rQ=="], "metro-file-map": ["metro-file-map@0.83.1", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-Yu429lnexKl44PttKw3nhqgmpBR+6UQ/tRaYcxPeEShtcza9DWakCn7cjqDTQZtWR2A8xSNv139izJMyQ4CG+w=="], - "metro-minify-terser": ["metro-minify-terser@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-kmooOxXLvKVxkh80IVSYO4weBdJDhCpg5NSPkjzzAnPJP43u6+usGXobkTWxxrAlq900bhzqKek4pBsUchlX6A=="], + "metro-inspector-proxy": ["metro-inspector-proxy@0.76.5", "", { "dependencies": { "connect": "^3.6.5", "debug": "^2.2.0", "node-fetch": "^2.2.0", "ws": "^7.5.1", "yargs": "^17.6.2" }, "bin": { "metro-inspector-proxy": "src/cli.js" } }, "sha512-leqwei1qNMKOEbhqlQ37K+7OIp1JRgvS5qERO+J0ZTg7ZeJTaBHSFU7FnCeRHB9Tu7/FSfypY2PxjydZDwvUEQ=="], + + "metro-minify-terser": ["metro-minify-terser@0.76.5", "", { "dependencies": { "terser": "^5.15.0" } }, "sha512-zizTXqlHcG7PArB5hfz1Djz/oCaOaTSXTZDNp8Y9K2FmmfLU3dU2eoDbNNiCnm5QdDtFIndLMXdqqe6omTfp4g=="], + + "metro-minify-uglify": ["metro-minify-uglify@0.76.5", "", { "dependencies": { "uglify-es": "^3.1.9" } }, "sha512-JZNO5eK8r625/cheWSl+y7n0RlHLt03iSMgXPAxirH8BiFqPzs7h+c57r4AvSs793VXcF7L3sI1sAOj+nRqTeg=="], - "metro-resolver": ["metro-resolver@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-t8j46kiILAqqFS5RNa+xpQyVjULxRxlvMidqUswPEk5nQVNdlJslqizDm/Et3v/JKwOtQGkYAQCHxP1zGStR/g=="], + "metro-react-native-babel-preset": ["metro-react-native-babel-preset@0.76.9", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/plugin-proposal-async-generator-functions": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.18.0", "@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.0", "@babel/plugin-proposal-numeric-separator": "^7.0.0", "@babel/plugin-proposal-object-rest-spread": "^7.20.0", "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", "@babel/plugin-proposal-optional-chaining": "^7.20.0", "@babel/plugin-syntax-dynamic-import": "^7.8.0", "@babel/plugin-syntax-export-default-from": "^7.0.0", "@babel/plugin-syntax-flow": "^7.18.0", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.0.0", "@babel/plugin-syntax-optional-chaining": "^7.0.0", "@babel/plugin-transform-arrow-functions": "^7.0.0", "@babel/plugin-transform-async-to-generator": "^7.20.0", "@babel/plugin-transform-block-scoping": "^7.0.0", "@babel/plugin-transform-classes": "^7.0.0", "@babel/plugin-transform-computed-properties": "^7.0.0", "@babel/plugin-transform-destructuring": "^7.20.0", "@babel/plugin-transform-flow-strip-types": "^7.20.0", "@babel/plugin-transform-function-name": "^7.0.0", "@babel/plugin-transform-literals": "^7.0.0", "@babel/plugin-transform-modules-commonjs": "^7.0.0", "@babel/plugin-transform-named-capturing-groups-regex": "^7.0.0", "@babel/plugin-transform-parameters": "^7.0.0", "@babel/plugin-transform-react-display-name": "^7.0.0", "@babel/plugin-transform-react-jsx": "^7.0.0", "@babel/plugin-transform-react-jsx-self": "^7.0.0", "@babel/plugin-transform-react-jsx-source": "^7.0.0", "@babel/plugin-transform-runtime": "^7.0.0", "@babel/plugin-transform-shorthand-properties": "^7.0.0", "@babel/plugin-transform-spread": "^7.0.0", "@babel/plugin-transform-sticky-regex": "^7.0.0", "@babel/plugin-transform-typescript": "^7.5.0", "@babel/plugin-transform-unicode-regex": "^7.0.0", "@babel/template": "^7.0.0", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.4.0" } }, "sha512-eCBtW/UkJPDr6HlMgFEGF+964DZsUEF9RGeJdZLKWE7d/0nY3ABZ9ZAGxzu9efQ35EWRox5bDMXUGaOwUe5ikQ=="], - "metro-runtime": ["metro-runtime@0.83.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-3Ag8ZS4IwafL/JUKlaeM6/CbkooY+WcVeqdNlBG0m4S0Qz0om3rdFdy1y6fYBpl6AwXJwWeMuXrvZdMuByTcRA=="], + "metro-react-native-babel-transformer": ["metro-react-native-babel-transformer@0.76.9", "", { "dependencies": { "@babel/core": "^7.20.0", "babel-preset-fbjs": "^3.4.0", "hermes-parser": "0.12.0", "metro-react-native-babel-preset": "0.76.9", "nullthrows": "^1.1.1" } }, "sha512-xXzHcfngSIkbQj+U7i/anFkNL0q2QVarYSzr34CFkzKLa79Rp16B8ki7z9eVVqo9W3B4TBcTXl3BipgRoOoZSQ=="], + + "metro-resolver": ["metro-resolver@0.76.5", "", {}, "sha512-QNsbDdf0xL1HefP6fhh1g3umqiX1qWEuCiBaTFroYRqM7u7RATt8mCu4n/FwSYhATuUUujHTIb2EduuQPbSGRQ=="], + + "metro-runtime": ["metro-runtime@0.76.9", "", { "dependencies": { "@babel/runtime": "^7.0.0", "react-refresh": "^0.4.0" } }, "sha512-/5vezDpGUtA0Fv6cJg0+i6wB+QeBbvLeaw9cTSG7L76liP0b91f8vOcYzGaUbHI8pznJCCTerxRzpQ8e3/NcDw=="], "metro-source-map": ["metro-source-map@0.83.1", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.1", "nullthrows": "^1.1.1", "ob1": "0.83.1", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-De7Vbeo96fFZ2cqmI0fWwVJbtHIwPZv++LYlWSwzTiCzxBDJORncN0LcT48Vi2UlQLzXJg+/CuTAcy7NBVh69A=="], @@ -1179,7 +1305,7 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -1189,11 +1315,11 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], @@ -1207,18 +1333,26 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "nested-error-stacks": ["nested-error-stacks@2.0.1", "", {}, "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A=="], + "nocache": ["nocache@3.0.4", "", {}, "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw=="], + + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="], "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + "node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], @@ -1241,9 +1375,9 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="], - "ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -1253,7 +1387,7 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], "parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="], @@ -1279,6 +1413,8 @@ "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], @@ -1291,6 +1427,8 @@ "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], @@ -1347,7 +1485,7 @@ "react-native-worklets": ["react-native-worklets@0.6.0", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-yETMNuCcivdYWteuG4eRqgiAk2DzRCrVAaEBIEWPo4emrf3BNjadFo85L5QvyEusrX9QKE3ZEAx8U5A/nbyFgg=="], - "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + "react-refresh": ["react-refresh@0.4.3", "", {}, "sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA=="], "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], @@ -1359,6 +1497,10 @@ "react-test-renderer": ["react-test-renderer@19.1.0", "", { "dependencies": { "react-is": "^19.1.0", "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readline": ["readline@1.3.0", "", {}, "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -1377,10 +1519,14 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "requireg": ["requireg@0.2.2", "", { "dependencies": { "nested-error-stacks": "~2.0.1", "rc": "~1.2.7", "resolve": "~1.7.1" } }, "sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg=="], "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="], @@ -1393,7 +1539,7 @@ "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], - "restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], @@ -1421,6 +1567,8 @@ "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sf-symbols-typescript": ["sf-symbols-typescript@2.1.0", "", {}, "sha512-ezT7gu/SHTPIOEEoG6TF+O0m5eewl0ZDAO4AtdBi5HjsrUI6JdCG17+Q8+aKp0heM06wZKApRCn5olNbs0Wb/A=="], @@ -1443,6 +1591,8 @@ "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="], + "slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="], "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], @@ -1479,7 +1629,9 @@ "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1491,10 +1643,14 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "structured-headers": ["structured-headers@0.4.1", "", {}, "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="], "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], + "sudo-prompt": ["sudo-prompt@9.2.1", "", {}, "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], @@ -1523,6 +1679,8 @@ "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], + "through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1531,7 +1689,7 @@ "tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], - "tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], @@ -1543,6 +1701,8 @@ "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + "uglify-es": ["uglify-es@3.3.9", "", { "dependencies": { "commander": "~2.13.0", "source-map": "~0.6.1" }, "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ=="], + "undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="], "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], @@ -1557,7 +1717,7 @@ "unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="], - "universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -1573,6 +1733,8 @@ "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], @@ -1609,12 +1771,14 @@ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], - "whatwg-url": ["whatwg-url@11.0.0", "", { "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" } }, "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "whatwg-url-without-unicode": ["whatwg-url-without-unicode@8.0.0-3", "", { "dependencies": { "buffer": "^5.4.3", "punycode": "^2.1.1", "webidl-conversions": "^5.0.0" } }, "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1637,10 +1801,14 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1653,10 +1821,24 @@ "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "@babel/plugin-transform-block-scoped-functions/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/plugin-transform-member-expression-literals/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/plugin-transform-object-super/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/plugin-transform-property-literals/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + "@expo/cli/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "@expo/cli/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + "@expo/cli/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + "@expo/cli/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@expo/cli/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], @@ -1685,6 +1867,8 @@ "@expo/fingerprint/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + "@expo/fingerprint/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@expo/fingerprint/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], @@ -1695,10 +1879,28 @@ "@expo/mcp-tunnel/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "@expo/metro/metro": ["metro@0.83.1", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.1", "metro-cache": "0.83.1", "metro-cache-key": "0.83.1", "metro-config": "0.83.1", "metro-core": "0.83.1", "metro-file-map": "0.83.1", "metro-resolver": "0.83.1", "metro-runtime": "0.83.1", "metro-source-map": "0.83.1", "metro-symbolicate": "0.83.1", "metro-transform-plugins": "0.83.1", "metro-transform-worker": "0.83.1", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-UGKepmTxoGD4HkQV8YWvpvwef7fUujNtTgG4Ygf7m/M0qjvb9VuDmAsEU+UdriRX7F61pnVK/opz89hjKlYTXA=="], + + "@expo/metro/metro-cache": ["metro-cache@0.83.1", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.1" } }, "sha512-7N/Ad1PHa1YMWDNiyynTPq34Op2qIE68NWryGEQ4TSE3Zy6a8GpsYnEEZE4Qi6aHgsE+yZHKkRczeBgxhnFIxQ=="], + + "@expo/metro/metro-config": ["metro-config@0.83.1", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.1", "metro-cache": "0.83.1", "metro-core": "0.83.1", "metro-runtime": "0.83.1" } }, "sha512-HJhpZx3wyOkux/jeF1o7akFJzZFdbn6Zf7UQqWrvp7gqFqNulQ8Mju09raBgPmmSxKDl4LbbNeigkX0/nKY1QA=="], + + "@expo/metro/metro-core": ["metro-core@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.1" } }, "sha512-uVL1eAJcMFd2o2Q7dsbpg8COaxjZBBGaXqO2OHnivpCdfanraVL8dPmY6It9ZeqWLOihUKZ2yHW4b6soVCzH/Q=="], + + "@expo/metro/metro-resolver": ["metro-resolver@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-t8j46kiILAqqFS5RNa+xpQyVjULxRxlvMidqUswPEk5nQVNdlJslqizDm/Et3v/JKwOtQGkYAQCHxP1zGStR/g=="], + + "@expo/metro/metro-runtime": ["metro-runtime@0.83.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-3Ag8ZS4IwafL/JUKlaeM6/CbkooY+WcVeqdNlBG0m4S0Qz0om3rdFdy1y6fYBpl6AwXJwWeMuXrvZdMuByTcRA=="], + "@expo/metro-config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "@expo/metro-config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + "@expo/metro-config/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + + "@expo/metro-config/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + "@expo/prebuild-config/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], @@ -1713,42 +1915,98 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@isaacs/fs-minipass/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "@jest/core/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@jest/reporters/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@jest/reporters/istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], + "@jest/reporters/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + "@jest/reporters/string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], + "@jest/reporters/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@react-native-community/cli-config/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@react-native-community/cli-platform-android/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@react-native-community/cli-platform-ios/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@react-native-community/cli-plugin-metro/metro-config": ["metro-config@0.76.5", "", { "dependencies": { "cosmiconfig": "^5.0.5", "jest-validate": "^29.2.1", "metro": "0.76.5", "metro-cache": "0.76.5", "metro-core": "0.76.5", "metro-runtime": "0.76.5" } }, "sha512-SCMVIDOtm8s3H62E9z2IcY4Q9GVMqDurbiJS3PHrWgTZjwZFaL59lrW4W6DvzvFZHa9bbxKric5TFtwvVuyOCg=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer": ["metro-react-native-babel-transformer@0.76.5", "", { "dependencies": { "@babel/core": "^7.20.0", "babel-preset-fbjs": "^3.4.0", "hermes-parser": "0.8.0", "metro-babel-transformer": "0.76.5", "metro-react-native-babel-preset": "0.76.5", "metro-source-map": "0.76.5", "nullthrows": "^1.1.1" } }, "sha512-7m2u7jQ1I2mwGm48Vrki5cNNSv4d2HegHMGmE5G2AAa6Pr2O3ajaX2yNoAKF8TCLO38/8pa9fZd0VWAlO/YMcA=="], + + "@react-native-community/cli-plugin-metro/metro-runtime": ["metro-runtime@0.76.5", "", { "dependencies": { "@babel/runtime": "^7.0.0", "react-refresh": "^0.4.0" } }, "sha512-1JAf9/v/NDHLhoTfiJ0n25G6dRkX7mjTkaMJ6UUXIyfIuSucoK5yAuOBx8OveNIekoLRjmyvSmyN5ojEeRmpvQ=="], + + "@react-native-community/cli-server-api/pretty-format": ["pretty-format@26.6.2", "", { "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^17.0.1" } }, "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg=="], + + "@react-native-community/cli-server-api/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "@react-native-community/cli-tools/find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.81.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.29.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-LWTGUTzFu+qOQnvkzBP52B90Ym3stZT8IFCzzUrppz8Iwglg83FCtDZAR4yLHI29VY/x/+pkcWAMCl3739XHdw=="], + "@react-native/babel-preset/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + + "@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@react-native/codegen/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + "@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.81.5", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.81.5", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA=="], + "@react-native/community-cli-plugin/metro": ["metro@0.83.1", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.1", "metro-cache": "0.83.1", "metro-cache-key": "0.83.1", "metro-config": "0.83.1", "metro-core": "0.83.1", "metro-file-map": "0.83.1", "metro-resolver": "0.83.1", "metro-runtime": "0.83.1", "metro-source-map": "0.83.1", "metro-symbolicate": "0.83.1", "metro-transform-plugins": "0.83.1", "metro-transform-worker": "0.83.1", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-UGKepmTxoGD4HkQV8YWvpvwef7fUujNtTgG4Ygf7m/M0qjvb9VuDmAsEU+UdriRX7F61pnVK/opz89hjKlYTXA=="], + + "@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.1", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.1", "metro-cache": "0.83.1", "metro-core": "0.83.1", "metro-runtime": "0.83.1" } }, "sha512-HJhpZx3wyOkux/jeF1o7akFJzZFdbn6Zf7UQqWrvp7gqFqNulQ8Mju09raBgPmmSxKDl4LbbNeigkX0/nKY1QA=="], + + "@react-native/community-cli-plugin/metro-core": ["metro-core@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.1" } }, "sha512-uVL1eAJcMFd2o2Q7dsbpg8COaxjZBBGaXqO2OHnivpCdfanraVL8dPmY6It9ZeqWLOihUKZ2yHW4b6soVCzH/Q=="], + "@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "@types/react-test-renderer/@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="], + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + + "babel-preset-expo/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + + "babel-preset-fbjs/@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-flow": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ=="], + "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="], - "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "compression/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + "chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "cosmiconfig/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "cssstyle/cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="], + "data-urls/whatwg-url": ["whatwg-url@11.0.0", "", { "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" } }, "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ=="], + "domexception/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -1757,6 +2015,8 @@ "eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "expo/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + "expo-build-properties/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], @@ -1777,7 +2037,7 @@ "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "hermes-profile-transformer/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -1787,14 +2047,22 @@ "istanbul-lib-source-maps/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "jest-config/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "jest-config/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "jest-haste-map/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "jest-runner/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + "jest-runner/source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], + "jest-runtime/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "jest-snapshot/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "jest-validate/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - "jest-watch-select-projects/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "jest-watch-typeahead/ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="], @@ -1809,32 +2077,84 @@ "jsdom/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "jsdom/whatwg-url": ["whatwg-url@11.0.0", "", { "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" } }, "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ=="], + "jsdom/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "make-dir/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + "metro/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "metro/hermes-parser": ["hermes-parser@0.8.0", "", { "dependencies": { "hermes-estree": "0.8.0" } }, "sha512-yZKalg1fTYG5eOiToLUaw69rQfZq/fi+/NtEXRU7N87K/XobNRhRWorh80oSge2lWUiZfTgUvRJH+XgZWrhoqA=="], + + "metro/metro-babel-transformer": ["metro-babel-transformer@0.76.5", "", { "dependencies": { "@babel/core": "^7.20.0", "hermes-parser": "0.8.0", "metro-source-map": "0.76.5", "nullthrows": "^1.1.1" } }, "sha512-KmsMXY6VHjPLRQLwTITjLo//7ih8Ts39HPF2zODkaYav/ZLNq0QP7eGuW54dvk/sZiL9le1kaBwTN4BWQI1VZQ=="], + + "metro/metro-cache": ["metro-cache@0.76.5", "", { "dependencies": { "metro-core": "0.76.5", "rimraf": "^3.0.2" } }, "sha512-8XalhoMNWDK6bi41oqxIpecTYRt4WsmtoHdqshgJIYshJ6qov0NuDw0pOfnS8rgMNHxPpuWyXc7NyKERqVRzaw=="], + + "metro/metro-cache-key": ["metro-cache-key@0.76.5", "", {}, "sha512-QERX6ejYMt4BPr0ZMf7adnrOivmFSUbCim9FlU6cAeWUib+pV5P/Ph3KicWnOzJpbQz93+tHHG7vcsP6OrvLMw=="], + + "metro/metro-config": ["metro-config@0.76.5", "", { "dependencies": { "cosmiconfig": "^5.0.5", "jest-validate": "^29.2.1", "metro": "0.76.5", "metro-cache": "0.76.5", "metro-core": "0.76.5", "metro-runtime": "0.76.5" } }, "sha512-SCMVIDOtm8s3H62E9z2IcY4Q9GVMqDurbiJS3PHrWgTZjwZFaL59lrW4W6DvzvFZHa9bbxKric5TFtwvVuyOCg=="], + + "metro/metro-file-map": ["metro-file-map@0.76.5", "", { "dependencies": { "anymatch": "^3.0.3", "debug": "^2.2.0", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-regex-util": "^27.0.6", "jest-util": "^27.2.0", "jest-worker": "^27.2.0", "micromatch": "^4.0.4", "node-abort-controller": "^3.1.1", "nullthrows": "^1.1.1", "walker": "^1.0.7" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-9VS7zsec7BpTb+0v1DObOXso6XU/7oVBObQWp0EWBQpFcU1iF1lit2nnLQh2AyGCnSr8JVnuUe8gXhNH6xtPMg=="], + + "metro/metro-react-native-babel-preset": ["metro-react-native-babel-preset@0.76.5", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/plugin-proposal-async-generator-functions": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.18.0", "@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.0", "@babel/plugin-proposal-numeric-separator": "^7.0.0", "@babel/plugin-proposal-object-rest-spread": "^7.20.0", "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", "@babel/plugin-proposal-optional-chaining": "^7.20.0", "@babel/plugin-syntax-dynamic-import": "^7.8.0", "@babel/plugin-syntax-export-default-from": "^7.0.0", "@babel/plugin-syntax-flow": "^7.18.0", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.0.0", "@babel/plugin-syntax-optional-chaining": "^7.0.0", "@babel/plugin-transform-arrow-functions": "^7.0.0", "@babel/plugin-transform-async-to-generator": "^7.20.0", "@babel/plugin-transform-block-scoping": "^7.0.0", "@babel/plugin-transform-classes": "^7.0.0", "@babel/plugin-transform-computed-properties": "^7.0.0", "@babel/plugin-transform-destructuring": "^7.20.0", "@babel/plugin-transform-flow-strip-types": "^7.20.0", "@babel/plugin-transform-function-name": "^7.0.0", "@babel/plugin-transform-literals": "^7.0.0", "@babel/plugin-transform-modules-commonjs": "^7.0.0", "@babel/plugin-transform-named-capturing-groups-regex": "^7.0.0", "@babel/plugin-transform-parameters": "^7.0.0", "@babel/plugin-transform-react-display-name": "^7.0.0", "@babel/plugin-transform-react-jsx": "^7.0.0", "@babel/plugin-transform-react-jsx-self": "^7.0.0", "@babel/plugin-transform-react-jsx-source": "^7.0.0", "@babel/plugin-transform-runtime": "^7.0.0", "@babel/plugin-transform-shorthand-properties": "^7.0.0", "@babel/plugin-transform-spread": "^7.0.0", "@babel/plugin-transform-sticky-regex": "^7.0.0", "@babel/plugin-transform-typescript": "^7.5.0", "@babel/plugin-transform-unicode-regex": "^7.0.0", "@babel/template": "^7.0.0", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.4.0" } }, "sha512-IlVKeTon5fef77rQ6WreSmrabmbc3dEsLwr/sL80fYjobjsD8FRCnOlbaJdgUf2SMJmSIoawgjh5Yeebv+gJzg=="], + + "metro/metro-runtime": ["metro-runtime@0.76.5", "", { "dependencies": { "@babel/runtime": "^7.0.0", "react-refresh": "^0.4.0" } }, "sha512-1JAf9/v/NDHLhoTfiJ0n25G6dRkX7mjTkaMJ6UUXIyfIuSucoK5yAuOBx8OveNIekoLRjmyvSmyN5ojEeRmpvQ=="], + + "metro/metro-source-map": ["metro-source-map@0.76.5", "", { "dependencies": { "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", "invariant": "^2.2.4", "metro-symbolicate": "0.76.5", "nullthrows": "^1.1.1", "ob1": "0.76.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-1EhYPcoftONlvnOzgos7daE8hsJKOgSN3nD3Xf/yaY1F0aLeGeuWfpiNLLeFDNyUhfObHSuNxNhDQF/x1GFEbw=="], + + "metro/metro-symbolicate": ["metro-symbolicate@0.76.5", "", { "dependencies": { "invariant": "^2.2.4", "metro-source-map": "0.76.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "through2": "^2.0.1", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-7iftzh6G6HO4UDBmjsi2Yu4d6IkApv6Kg+jmBvkTjCXr8HwnKKum89gMg/FRMix+Rhhut0dnMpz6mAbtKTU9JQ=="], + + "metro/metro-transform-plugins": ["metro-transform-plugins@0.76.5", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/template": "^7.0.0", "@babel/traverse": "^7.20.0", "nullthrows": "^1.1.1" } }, "sha512-7pJ24aRuvzdQYpX/eOyodr4fnwVJP5ArNLBE1d0DOU9sQxsGplOORDTGAqw2L01+UgaSJiiwEoFMw7Z91HAS+Q=="], + + "metro/metro-transform-worker": ["metro-transform-worker@0.76.5", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "babel-preset-fbjs": "^3.4.0", "metro": "0.76.5", "metro-babel-transformer": "0.76.5", "metro-cache": "0.76.5", "metro-cache-key": "0.76.5", "metro-source-map": "0.76.5", "metro-transform-plugins": "0.76.5", "nullthrows": "^1.1.1" } }, "sha512-xN6Kb06o9u5A7M1bbl7oPfQFmt4Kmi3CMXp5j9OcK37AFc+u6YXH8x/6e9b3Cq50rlBYuCXDOOYAWI5/tYNt2w=="], + + "metro/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "metro-babel-transformer/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + + "metro-cache/metro-core": ["metro-core@0.76.9", "", { "dependencies": { "lodash.throttle": "^4.1.1", "metro-resolver": "0.76.9" } }, "sha512-DSeEr43Wrd5Q7ySfRzYzDwfV89g2OZTQDf1s3exOcLjE5fb7awoLOkA2h46ZzN8NcmbbM0cuJy6hOwF073/yRQ=="], + + "metro-config/metro": ["metro@0.76.9", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/parser": "^7.20.0", "@babel/template": "^7.0.0", "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", "accepts": "^1.3.7", "async": "^3.2.2", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^2.2.0", "denodeify": "^1.2.1", "error-stack-parser": "^2.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.12.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^27.2.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.76.9", "metro-cache": "0.76.9", "metro-cache-key": "0.76.9", "metro-config": "0.76.9", "metro-core": "0.76.9", "metro-file-map": "0.76.9", "metro-inspector-proxy": "0.76.9", "metro-minify-uglify": "0.76.9", "metro-react-native-babel-preset": "0.76.9", "metro-resolver": "0.76.9", "metro-runtime": "0.76.9", "metro-source-map": "0.76.9", "metro-symbolicate": "0.76.9", "metro-transform-plugins": "0.76.9", "metro-transform-worker": "0.76.9", "mime-types": "^2.1.27", "node-fetch": "^2.2.0", "nullthrows": "^1.1.1", "rimraf": "^3.0.2", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "strip-ansi": "^6.0.0", "throat": "^5.0.0", "ws": "^7.5.1", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-gcjcfs0l5qIPg0lc5P7pj0x7vPJ97tan+OnEjiYLbKjR1D7Oa78CE93YUPyymUPH6q7VzlzMm1UjT35waEkZUw=="], + + "metro-config/metro-core": ["metro-core@0.76.9", "", { "dependencies": { "lodash.throttle": "^4.1.1", "metro-resolver": "0.76.9" } }, "sha512-DSeEr43Wrd5Q7ySfRzYzDwfV89g2OZTQDf1s3exOcLjE5fb7awoLOkA2h46ZzN8NcmbbM0cuJy6hOwF073/yRQ=="], + + "metro-file-map/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "metro-inspector-proxy/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "metro-inspector-proxy/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "metro-transform-worker/metro": ["metro@0.83.1", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.1", "metro-cache": "0.83.1", "metro-cache-key": "0.83.1", "metro-config": "0.83.1", "metro-core": "0.83.1", "metro-file-map": "0.83.1", "metro-resolver": "0.83.1", "metro-runtime": "0.83.1", "metro-source-map": "0.83.1", "metro-symbolicate": "0.83.1", "metro-transform-plugins": "0.83.1", "metro-transform-worker": "0.83.1", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-UGKepmTxoGD4HkQV8YWvpvwef7fUujNtTgG4Ygf7m/M0qjvb9VuDmAsEU+UdriRX7F61pnVK/opz89hjKlYTXA=="], + + "metro-transform-worker/metro-cache": ["metro-cache@0.83.1", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.1" } }, "sha512-7N/Ad1PHa1YMWDNiyynTPq34Op2qIE68NWryGEQ4TSE3Zy6a8GpsYnEEZE4Qi6aHgsE+yZHKkRczeBgxhnFIxQ=="], + + "metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-kmooOxXLvKVxkh80IVSYO4weBdJDhCpg5NSPkjzzAnPJP43u6+usGXobkTWxxrAlq900bhzqKek4pBsUchlX6A=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "npm-package-arg/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "minizlib/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - "ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "npm-package-arg/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], @@ -1845,8 +2165,18 @@ "react-dom/scheduler": ["scheduler@0.25.0-rc-6230622a1a-20240610", "", {}, "sha512-GTIQdJXthps5mgkIFo7yAq03M0QQYTfN8z+GrnMC/SCKFSuyFP5tk2BMaaWUsVy4u4r+dTLdiXH8JEivVls0Bw=="], + "react-native/@react-native/js-polyfills": ["@react-native/js-polyfills@0.81.5", "", {}, "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w=="], + "react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="], + "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "react-native/metro-runtime": ["metro-runtime@0.83.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-3Ag8ZS4IwafL/JUKlaeM6/CbkooY+WcVeqdNlBG0m4S0Qz0om3rdFdy1y6fYBpl6AwXJwWeMuXrvZdMuByTcRA=="], + + "react-native/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + "react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -1857,14 +2187,20 @@ "requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="], - "restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], + "slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], @@ -1873,19 +2209,41 @@ "string-length/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + "tar/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "terser-webpack-plugin/jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "whatwg-url/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], + + "uglify-es/commander": ["commander@2.13.0", "", {}, "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA=="], + + "uglify-es/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], @@ -1895,6 +2253,54 @@ "@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@expo/cli/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "@expo/cli/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "@expo/cli/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + + "@expo/cli/ora/log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + + "@expo/config-plugins/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@expo/config-plugins/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "@expo/config/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@expo/config/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "@expo/devcert/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@expo/devcert/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "@expo/fingerprint/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "@expo/metro-config/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "@expo/metro-config/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + + "@expo/metro/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "@expo/metro/metro/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + + "@expo/metro/metro/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "@expo/metro/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "@expo/metro/metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "@expo/package-manager/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + + "@expo/package-manager/ora/log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + "@expo/xcpretty/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "@expo/xcpretty/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -1905,45 +2311,193 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "@jest/reporters/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@jest/reporters/istanbul-lib-instrument/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@jest/reporters/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "@jest/reporters/string-length/char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "@react-native-community/cli-config/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "@react-native-community/cli-platform-android/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "@react-native-community/cli-platform-ios/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "@react-native-community/cli-plugin-metro/metro-config/metro-cache": ["metro-cache@0.76.5", "", { "dependencies": { "metro-core": "0.76.5", "rimraf": "^3.0.2" } }, "sha512-8XalhoMNWDK6bi41oqxIpecTYRt4WsmtoHdqshgJIYshJ6qov0NuDw0pOfnS8rgMNHxPpuWyXc7NyKERqVRzaw=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/hermes-parser": ["hermes-parser@0.8.0", "", { "dependencies": { "hermes-estree": "0.8.0" } }, "sha512-yZKalg1fTYG5eOiToLUaw69rQfZq/fi+/NtEXRU7N87K/XobNRhRWorh80oSge2lWUiZfTgUvRJH+XgZWrhoqA=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/metro-babel-transformer": ["metro-babel-transformer@0.76.5", "", { "dependencies": { "@babel/core": "^7.20.0", "hermes-parser": "0.8.0", "metro-source-map": "0.76.5", "nullthrows": "^1.1.1" } }, "sha512-KmsMXY6VHjPLRQLwTITjLo//7ih8Ts39HPF2zODkaYav/ZLNq0QP7eGuW54dvk/sZiL9le1kaBwTN4BWQI1VZQ=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/metro-react-native-babel-preset": ["metro-react-native-babel-preset@0.76.5", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/plugin-proposal-async-generator-functions": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.18.0", "@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.0", "@babel/plugin-proposal-numeric-separator": "^7.0.0", "@babel/plugin-proposal-object-rest-spread": "^7.20.0", "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", "@babel/plugin-proposal-optional-chaining": "^7.20.0", "@babel/plugin-syntax-dynamic-import": "^7.8.0", "@babel/plugin-syntax-export-default-from": "^7.0.0", "@babel/plugin-syntax-flow": "^7.18.0", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.0.0", "@babel/plugin-syntax-optional-chaining": "^7.0.0", "@babel/plugin-transform-arrow-functions": "^7.0.0", "@babel/plugin-transform-async-to-generator": "^7.20.0", "@babel/plugin-transform-block-scoping": "^7.0.0", "@babel/plugin-transform-classes": "^7.0.0", "@babel/plugin-transform-computed-properties": "^7.0.0", "@babel/plugin-transform-destructuring": "^7.20.0", "@babel/plugin-transform-flow-strip-types": "^7.20.0", "@babel/plugin-transform-function-name": "^7.0.0", "@babel/plugin-transform-literals": "^7.0.0", "@babel/plugin-transform-modules-commonjs": "^7.0.0", "@babel/plugin-transform-named-capturing-groups-regex": "^7.0.0", "@babel/plugin-transform-parameters": "^7.0.0", "@babel/plugin-transform-react-display-name": "^7.0.0", "@babel/plugin-transform-react-jsx": "^7.0.0", "@babel/plugin-transform-react-jsx-self": "^7.0.0", "@babel/plugin-transform-react-jsx-source": "^7.0.0", "@babel/plugin-transform-runtime": "^7.0.0", "@babel/plugin-transform-shorthand-properties": "^7.0.0", "@babel/plugin-transform-spread": "^7.0.0", "@babel/plugin-transform-sticky-regex": "^7.0.0", "@babel/plugin-transform-typescript": "^7.5.0", "@babel/plugin-transform-unicode-regex": "^7.0.0", "@babel/template": "^7.0.0", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.4.0" } }, "sha512-IlVKeTon5fef77rQ6WreSmrabmbc3dEsLwr/sL80fYjobjsD8FRCnOlbaJdgUf2SMJmSIoawgjh5Yeebv+gJzg=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/metro-source-map": ["metro-source-map@0.76.5", "", { "dependencies": { "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", "invariant": "^2.2.4", "metro-symbolicate": "0.76.5", "nullthrows": "^1.1.1", "ob1": "0.76.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-1EhYPcoftONlvnOzgos7daE8hsJKOgSN3nD3Xf/yaY1F0aLeGeuWfpiNLLeFDNyUhfObHSuNxNhDQF/x1GFEbw=="], + + "@react-native-community/cli-server-api/pretty-format/@jest/types": ["@jest/types@26.6.2", "", { "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^15.0.0", "chalk": "^4.0.0" } }, "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ=="], + + "@react-native-community/cli-server-api/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "@react-native-community/cli-tools/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "@react-native/babel-plugin-codegen/@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@react-native/babel-plugin-codegen/@react-native/codegen/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + + "@react-native/codegen/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + "@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.81.5", "", {}, "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w=="], + "@react-native/community-cli-plugin/@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + + "@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "@react-native/community-cli-plugin/metro/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + + "@react-native/community-cli-plugin/metro/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.83.1", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.1" } }, "sha512-7N/Ad1PHa1YMWDNiyynTPq34Op2qIE68NWryGEQ4TSE3Zy6a8GpsYnEEZE4Qi6aHgsE+yZHKkRczeBgxhnFIxQ=="], + + "@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-t8j46kiILAqqFS5RNa+xpQyVjULxRxlvMidqUswPEk5nQVNdlJslqizDm/Et3v/JKwOtQGkYAQCHxP1zGStR/g=="], + + "@react-native/community-cli-plugin/metro/metro-runtime": ["metro-runtime@0.83.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-3Ag8ZS4IwafL/JUKlaeM6/CbkooY+WcVeqdNlBG0m4S0Qz0om3rdFdy1y6fYBpl6AwXJwWeMuXrvZdMuByTcRA=="], + + "@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.83.1", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.1" } }, "sha512-7N/Ad1PHa1YMWDNiyynTPq34Op2qIE68NWryGEQ4TSE3Zy6a8GpsYnEEZE4Qi6aHgsE+yZHKkRczeBgxhnFIxQ=="], + + "@react-native/community-cli-plugin/metro-config/metro-runtime": ["metro-runtime@0.83.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-3Ag8ZS4IwafL/JUKlaeM6/CbkooY+WcVeqdNlBG0m4S0Qz0om3rdFdy1y6fYBpl6AwXJwWeMuXrvZdMuByTcRA=="], + + "@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-t8j46kiILAqqFS5RNa+xpQyVjULxRxlvMidqUswPEk5nQVNdlJslqizDm/Et3v/JKwOtQGkYAQCHxP1zGStR/g=="], + + "@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + + "babel-preset-fbjs/@babel/plugin-transform-flow-strip-types/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "babel-preset-fbjs/@babel/plugin-transform-flow-strip-types/@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ=="], + + "better-opn/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "data-urls/whatwg-url/tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="], + + "data-urls/whatwg-url/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "expo-modules-autolinking/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "expo-modules-autolinking/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "jest-config/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "jest-haste-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "jest-runner/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "jest-runner/source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "jest-runtime/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "jest-watch-typeahead/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "jest-watcher/string-length/char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "jest-watcher/string-length/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "jsdom/whatwg-url/tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="], + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + "logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "logkitty/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "logkitty/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + + "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + + "metro-cache/metro-core/metro-resolver": ["metro-resolver@0.76.9", "", {}, "sha512-s86ipNRas9vNR5lChzzSheF7HoaQEmzxBLzwFA6/2YcGmUCowcoyPAfs1yPh4cjMw9F1T4KlMLaiwniGE7HCyw=="], + + "metro-config/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "metro-config/metro/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "metro-config/metro/metro-babel-transformer": ["metro-babel-transformer@0.76.9", "", { "dependencies": { "@babel/core": "^7.20.0", "hermes-parser": "0.12.0", "nullthrows": "^1.1.1" } }, "sha512-dAnAmBqRdTwTPVn4W4JrowPolxD1MDbuU97u3MqtWZgVRvDpmr+Cqnn5oSxLQk3Uc+Zy3wkqVrB/zXNRlLDSAQ=="], + + "metro-config/metro/metro-cache-key": ["metro-cache-key@0.76.9", "", {}, "sha512-ugJuYBLngHVh1t2Jj+uP9pSCQl7enzVXkuh6+N3l0FETfqjgOaSHlcnIhMPn6yueGsjmkiIfxQU4fyFVXRtSTw=="], + + "metro-config/metro/metro-file-map": ["metro-file-map@0.76.9", "", { "dependencies": { "anymatch": "^3.0.3", "debug": "^2.2.0", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-regex-util": "^27.0.6", "jest-util": "^27.2.0", "jest-worker": "^27.2.0", "micromatch": "^4.0.4", "node-abort-controller": "^3.1.1", "nullthrows": "^1.1.1", "walker": "^1.0.7" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-7vJd8kksMDTO/0fbf3081bTrlw8SLiploeDf+vkkf0OwlrtDUWPOikfebp+MpZB2S61kamKjCNRfRkgrbPfSwg=="], + + "metro-config/metro/metro-inspector-proxy": ["metro-inspector-proxy@0.76.9", "", { "dependencies": { "connect": "^3.6.5", "debug": "^2.2.0", "node-fetch": "^2.2.0", "ws": "^7.5.1", "yargs": "^17.6.2" }, "bin": { "metro-inspector-proxy": "src/cli.js" } }, "sha512-idIiPkb8CYshc0WZmbzwmr4B1QwsQUbpDwBzHwxE1ni27FWKWhV9CD5p+qlXZHgfwJuMRfPN+tIaLSR8+vttYg=="], + + "metro-config/metro/metro-minify-uglify": ["metro-minify-uglify@0.76.9", "", { "dependencies": { "uglify-es": "^3.1.9" } }, "sha512-MXRrM3lFo62FPISlPfTqC6n9HTEI3RJjDU5SvpE7sJFfJKLx02xXQEltsL/wzvEqK+DhRQ5DEYACTwf5W4Z3yA=="], + + "metro-config/metro/metro-resolver": ["metro-resolver@0.76.9", "", {}, "sha512-s86ipNRas9vNR5lChzzSheF7HoaQEmzxBLzwFA6/2YcGmUCowcoyPAfs1yPh4cjMw9F1T4KlMLaiwniGE7HCyw=="], + + "metro-config/metro/metro-source-map": ["metro-source-map@0.76.9", "", { "dependencies": { "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", "invariant": "^2.2.4", "metro-symbolicate": "0.76.9", "nullthrows": "^1.1.1", "ob1": "0.76.9", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-q5qsMlu8EFvsT46wUUh+ao+efDsicT30zmaPATNhq+PcTawDbDgnMuUD+FT0bvxxnisU2PWl91RdzKfNc2qPQA=="], + + "metro-config/metro/metro-symbolicate": ["metro-symbolicate@0.76.9", "", { "dependencies": { "invariant": "^2.2.4", "metro-source-map": "0.76.9", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "through2": "^2.0.1", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-Yyq6Ukj/IeWnGST09kRt0sBK8TwzGZWoU7YAcQlh14+AREH454Olx4wbFTpkkhUkV05CzNCvUuXQ0efFxhA1bw=="], + + "metro-config/metro/metro-transform-plugins": ["metro-transform-plugins@0.76.9", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/template": "^7.0.0", "@babel/traverse": "^7.20.0", "nullthrows": "^1.1.1" } }, "sha512-YEQeNlOCt92I7S9A3xbrfaDfwfgcxz9PpD/1eeop3c4cO3z3Q3otYuxw0WJ/rUIW8pZfOm5XCehd+1NRbWlAaw=="], + + "metro-config/metro/metro-transform-worker": ["metro-transform-worker@0.76.9", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "babel-preset-fbjs": "^3.4.0", "metro": "0.76.9", "metro-babel-transformer": "0.76.9", "metro-cache": "0.76.9", "metro-cache-key": "0.76.9", "metro-minify-terser": "0.76.9", "metro-source-map": "0.76.9", "metro-transform-plugins": "0.76.9", "nullthrows": "^1.1.1" } }, "sha512-F69A0q0qFdJmP2Clqr6TpTSn4WTV9p5A28h5t9o+mB22ryXBZfUQ6BFBBW/6Wp2k/UtPH+oOsBfV9guiqm3d2Q=="], + + "metro-config/metro/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "metro-config/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "metro-config/metro-core/metro-resolver": ["metro-resolver@0.76.9", "", {}, "sha512-s86ipNRas9vNR5lChzzSheF7HoaQEmzxBLzwFA6/2YcGmUCowcoyPAfs1yPh4cjMw9F1T4KlMLaiwniGE7HCyw=="], + + "metro-file-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "metro-inspector-proxy/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "metro-transform-worker/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "metro-transform-worker/metro/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + + "metro-transform-worker/metro/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + + "metro-transform-worker/metro/metro-config": ["metro-config@0.83.1", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.1", "metro-cache": "0.83.1", "metro-core": "0.83.1", "metro-runtime": "0.83.1" } }, "sha512-HJhpZx3wyOkux/jeF1o7akFJzZFdbn6Zf7UQqWrvp7gqFqNulQ8Mju09raBgPmmSxKDl4LbbNeigkX0/nKY1QA=="], + + "metro-transform-worker/metro/metro-core": ["metro-core@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.1" } }, "sha512-uVL1eAJcMFd2o2Q7dsbpg8COaxjZBBGaXqO2OHnivpCdfanraVL8dPmY6It9ZeqWLOihUKZ2yHW4b6soVCzH/Q=="], + + "metro-transform-worker/metro/metro-resolver": ["metro-resolver@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-t8j46kiILAqqFS5RNa+xpQyVjULxRxlvMidqUswPEk5nQVNdlJslqizDm/Et3v/JKwOtQGkYAQCHxP1zGStR/g=="], - "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "metro-transform-worker/metro/metro-runtime": ["metro-runtime@0.83.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-3Ag8ZS4IwafL/JUKlaeM6/CbkooY+WcVeqdNlBG0m4S0Qz0om3rdFdy1y6fYBpl6AwXJwWeMuXrvZdMuByTcRA=="], - "log-symbols/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "metro-transform-worker/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "metro-transform-worker/metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + "metro-transform-worker/metro-cache/metro-core": ["metro-core@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.1" } }, "sha512-uVL1eAJcMFd2o2Q7dsbpg8COaxjZBBGaXqO2OHnivpCdfanraVL8dPmY6It9ZeqWLOihUKZ2yHW4b6soVCzH/Q=="], - "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "metro/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "metro/hermes-parser/hermes-estree": ["hermes-estree@0.8.0", "", {}, "sha512-W6JDAOLZ5pMPMjEiQGLCXSSV7pIBEgRR5zGkxgmzGSXHOxqV5dC/M1Zevqpbm9TZDE5tu358qZf8Vkzmsc+u7Q=="], - "ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + "metro/metro-file-map/jest-regex-util": ["jest-regex-util@27.5.1", "", {}, "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg=="], - "restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + "metro/metro-file-map/jest-util": ["jest-util@27.5.1", "", { "dependencies": { "@jest/types": "^27.5.1", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw=="], + + "metro/metro-react-native-babel-preset/@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-flow": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ=="], + + "metro/metro-source-map/ob1": ["ob1@0.76.5", "", {}, "sha512-HoxZXMXNuY/eIXGoX7gx1C4O3eB4kJJMola6KoFaMm7PGGg39+AnhbgMASYVmSvP2lwU3545NyiR63g8J9PW3w=="], + + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + + "react-native/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1951,32 +2505,206 @@ "serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + "serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + "string-length/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], - "terser-webpack-plugin/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "sucrase/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "through2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@expo/cli/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "@expo/cli/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "@expo/cli/ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "@expo/cli/ora/cli-cursor/restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + + "@expo/metro/metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "@expo/metro/metro/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + + "@expo/metro/metro/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "@expo/package-manager/ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "@expo/package-manager/ora/cli-cursor/restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + "@expo/xcpretty/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + "@jest/reporters/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@react-native-community/cli-config/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@react-native-community/cli-platform-android/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@react-native-community/cli-platform-ios/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.8.0", "", {}, "sha512-W6JDAOLZ5pMPMjEiQGLCXSSV7pIBEgRR5zGkxgmzGSXHOxqV5dC/M1Zevqpbm9TZDE5tu358qZf8Vkzmsc+u7Q=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/metro-react-native-babel-preset/@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-flow": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.76.5", "", { "dependencies": { "invariant": "^2.2.4", "metro-source-map": "0.76.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "through2": "^2.0.1", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-7iftzh6G6HO4UDBmjsi2Yu4d6IkApv6Kg+jmBvkTjCXr8HwnKKum89gMg/FRMix+Rhhut0dnMpz6mAbtKTU9JQ=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/metro-source-map/ob1": ["ob1@0.76.5", "", {}, "sha512-HoxZXMXNuY/eIXGoX7gx1C4O3eB4kJJMola6KoFaMm7PGGg39+AnhbgMASYVmSvP2lwU3545NyiR63g8J9PW3w=="], + + "@react-native-community/cli-server-api/pretty-format/@jest/types/@types/yargs": ["@types/yargs@15.0.20", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-KIkX+/GgfFitlASYCGoSF+T4XRXhOubJLhkLVtSfsRTe9jWMmuM2g28zQ41BtPTG7TRBb2xHW+LCNVE9QR/vsg=="], + + "@react-native-community/cli-tools/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "@react-native/babel-plugin-codegen/@react-native/codegen/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "@react-native/babel-plugin-codegen/@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + + "@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@react-native/community-cli-plugin/@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "@react-native/community-cli-plugin/metro-config/metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + + "@react-native/community-cli-plugin/metro/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "@react-native/community-cli-plugin/metro/metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "jest-config/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "jest-runtime/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + "logkitty/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "logkitty/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "metro-config/metro/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "metro-config/metro/metro-file-map/jest-regex-util": ["jest-regex-util@27.5.1", "", {}, "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg=="], + + "metro-config/metro/metro-file-map/jest-util": ["jest-util@27.5.1", "", { "dependencies": { "@jest/types": "^27.5.1", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw=="], + + "metro-config/metro/metro-source-map/ob1": ["ob1@0.76.9", "", {}, "sha512-g0I/OLnSxf6OrN3QjSew3bTDJCdbZoWxnh8adh1z36alwCuGF1dgDeRA25bTYSakrG5WULSaWJPOdgnf1O/oQw=="], + + "metro-config/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.76.9", "", { "dependencies": { "terser": "^5.15.0" } }, "sha512-ju2nUXTKvh96vHPoGZH/INhSvRRKM14CbGAJXQ98+g8K5z1v3luYJ/7+dFQB202eVzJdTB2QMtBjI1jUUpooCg=="], + + "metro-transform-worker/metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "metro-transform-worker/metro-cache/metro-core/metro-resolver": ["metro-resolver@0.83.1", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-t8j46kiILAqqFS5RNa+xpQyVjULxRxlvMidqUswPEk5nQVNdlJslqizDm/Et3v/JKwOtQGkYAQCHxP1zGStR/g=="], + + "metro-transform-worker/metro/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + + "metro-transform-worker/metro/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "metro/metro-file-map/jest-util/@jest/types": ["@jest/types@27.5.1", "", { "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^16.0.0", "chalk": "^4.0.0" } }, "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw=="], + + "metro/metro-file-map/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "metro/metro-file-map/jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "metro/metro-react-native-babel-preset/@babel/plugin-transform-flow-strip-types/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "metro/metro-react-native-babel-preset/@babel/plugin-transform-flow-strip-types/@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ=="], + + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + + "react-native/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/helper-optimise-call-expression/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-transform-object-super/@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@expo/cli/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "@expo/cli/ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "@expo/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + + "@expo/package-manager/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "@expo/package-manager/ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "@expo/package-manager/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/metro-react-native-babel-preset/@babel/plugin-transform-flow-strip-types/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@react-native-community/cli-plugin-metro/metro-react-native-babel-transformer/metro-react-native-babel-preset/@babel/plugin-transform-flow-strip-types/@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ=="], + + "@react-native/babel-plugin-codegen/@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "@react-native/community-cli-plugin/metro-config/metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "@react-native/community-cli-plugin/metro/metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "metro-config/metro/metro-file-map/jest-util/@jest/types": ["@jest/types@27.5.1", "", { "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^16.0.0", "chalk": "^4.0.0" } }, "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw=="], + + "metro-config/metro/metro-file-map/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "metro-config/metro/metro-file-map/jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "metro/metro-file-map/jest-util/@jest/types/@types/yargs": ["@types/yargs@16.0.11", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g=="], + + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + + "@expo/package-manager/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "@expo/package-manager/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], - "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "metro-config/metro/metro-file-map/jest-util/@jest/types/@types/yargs": ["@types/yargs@16.0.11", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g=="], } } diff --git a/libraries/expo-iap/example/jest.setup.js b/libraries/expo-iap/example/jest.setup.js index 6644704b..084af0f9 100644 --- a/libraries/expo-iap/example/jest.setup.js +++ b/libraries/expo-iap/example/jest.setup.js @@ -52,9 +52,19 @@ jest.mock('../../src', () => { initConnection: jest.fn(), endConnection: jest.fn(), fetchProducts: jest.fn(), - requestPurchase: jest.fn(), + requestPurchase: jest.fn(() => Promise.resolve()), finishTransaction: jest.fn(), getAvailablePurchases: jest.fn(), + verifyPurchase: jest.fn(() => Promise.resolve({})), + verifyPurchaseWithProvider: jest.fn(() => + Promise.resolve({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'amazon', + }, + }), + ), // iOS functions with IOS suffix syncIOS: jest.fn(), @@ -68,11 +78,15 @@ jest.mock('../../src', () => { isTransactionVerifiedIOS: jest.fn(), getTransactionJwsIOS: jest.fn(), presentCodeRedemptionSheetIOS: jest.fn(), + presentExternalPurchaseLinkIOS: jest.fn(() => + Promise.resolve({success: true}), + ), getAppTransactionIOS: jest.fn(), validateReceiptIOS: jest.fn(), // Cross-platform storefront helper getStorefront: jest.fn(), + deepLinkToSubscriptions: jest.fn(() => Promise.resolve(true)), connectWebhookStream: jest.fn(() => ({ close: jest.fn(), })), @@ -101,10 +115,20 @@ jest.mock('../../src', () => { currentPurchase: null, currentPurchaseError: null, fetchProducts: jest.fn(), - requestPurchase: jest.fn(), + requestPurchase: jest.fn(() => Promise.resolve()), getAvailablePurchases: jest.fn(), finishTransaction: jest.fn(), getActiveSubscriptions: jest.fn(), + verifyPurchase: jest.fn(() => Promise.resolve({})), + verifyPurchaseWithProvider: jest.fn(() => + Promise.resolve({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'amazon', + }, + }), + ), })), // Enums @@ -142,7 +166,7 @@ jest.mock('expo-iap', () => { const mockGetAvailablePurchases = jest.fn(); const mockFinishTransaction = jest.fn(); const mockGetActiveSubscriptions = jest.fn(); - const mockRequestPurchase = jest.fn(); + const mockRequestPurchase = jest.fn(() => Promise.resolve()); return { // Core functions @@ -154,6 +178,16 @@ jest.mock('expo-iap', () => { finishTransaction: mockFinishTransaction, getAvailablePurchases: mockGetAvailablePurchases, + verifyPurchase: jest.fn(() => Promise.resolve({})), + verifyPurchaseWithProvider: jest.fn(() => + Promise.resolve({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'amazon', + }, + }), + ), // iOS functions with IOS suffix syncIOS: jest.fn(), @@ -167,11 +201,15 @@ jest.mock('expo-iap', () => { isTransactionVerifiedIOS: jest.fn(), getTransactionJwsIOS: jest.fn(), presentCodeRedemptionSheetIOS: jest.fn(), + presentExternalPurchaseLinkIOS: jest.fn(() => + Promise.resolve({success: true}), + ), getAppTransactionIOS: jest.fn(), validateReceiptIOS: jest.fn(), // Cross-platform storefront helper getStorefront: jest.fn(), + deepLinkToSubscriptions: jest.fn(() => Promise.resolve(true)), connectWebhookStream: jest.fn(() => ({ close: jest.fn(), })), @@ -205,6 +243,16 @@ jest.mock('expo-iap', () => { getAvailablePurchases: mockGetAvailablePurchases, finishTransaction: mockFinishTransaction, getActiveSubscriptions: mockGetActiveSubscriptions, + verifyPurchase: jest.fn(() => Promise.resolve({})), + verifyPurchaseWithProvider: jest.fn(() => + Promise.resolve({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'amazon', + }, + }), + ), })), // Enums diff --git a/libraries/expo-iap/example/metro.config.js b/libraries/expo-iap/example/metro.config.js index b9169eed..4aa14a6f 100644 --- a/libraries/expo-iap/example/metro.config.js +++ b/libraries/expo-iap/example/metro.config.js @@ -3,49 +3,126 @@ const {getDefaultConfig} = require('expo/metro-config'); const path = require('path'); const fs = require('fs'); +const isVega = process.env.EXPO_IAP_VEGA === '1'; + // Read library version mode from libraries-versions.jsonc const parseJsonc = (text) => JSON.parse(text.replace(/^\s*\/\/.*$/gm, '')); let useLocalDev = true; -const versionsPath = path.resolve(__dirname, '../../../libraries-versions.jsonc'); +const versionsPath = path.resolve( + __dirname, + '../../../libraries-versions.jsonc', +); if (fs.existsSync(versionsPath)) { const librariesVersions = parseJsonc(fs.readFileSync(versionsPath, 'utf8')); - useLocalDev = !librariesVersions['expo-iap'] || librariesVersions['expo-iap'] === 'local'; + useLocalDev = + !librariesVersions['expo-iap'] || librariesVersions['expo-iap'] === 'local'; } -const config = getDefaultConfig(__dirname); - -// Exclude test files from bundling -config.resolver.blockList = [ - ...Array.from(config.resolver.blockList ?? []), - /.*\/__tests__\/.*/, - /.*\.test\.(js|jsx|ts|tsx)$/, - /.*\.spec\.(js|jsx|ts|tsx)$/, -]; - -if (useLocalDev) { - // Local development: resolve expo-iap from parent directory source - config.resolver.blockList.push( - new RegExp(path.resolve(__dirname, '..', 'node_modules', 'react')), - new RegExp(path.resolve(__dirname, '..', 'node_modules', 'react-native')), +if (isVega) { + const { + getDefaultConfig: getReactNativeDefaultConfig, + mergeConfig, + } = require('@react-native/metro-config'); + const { + getKeplerCompatibilityMetroConfig, + } = require('@amazon-devices/kepler-compatibility-metro-config'); + + const keplerReactNativeRoot = path.resolve( + __dirname, + 'node_modules', + '@amazon-devices', + 'react-native-kepler', ); + const expoIapRoot = path.resolve(__dirname, '..'); + const resolveKeplerReactNativeFile = (moduleName) => { + const relativePath = + moduleName === 'react-native' + ? 'index.js' + : moduleName.replace(/^react-native\//, ''); + const basePath = path.join(keplerReactNativeRoot, relativePath); + const candidates = [ + basePath, + `${basePath}.js`, + `${basePath}.ts`, + `${basePath}.tsx`, + `${basePath}.json`, + ]; - config.resolver.nodeModulesPaths = [ - path.resolve(__dirname, './node_modules'), - path.resolve(__dirname, '../node_modules'), - ]; + return candidates.find((candidate) => fs.existsSync(candidate)) ?? basePath; + }; - config.resolver.extraNodeModules = { - 'expo-iap': path.resolve(__dirname, '..'), + const vegaConfig = { + resolver: { + resolveRequest: (context, moduleName, platform) => { + if (moduleName === 'react-native') { + return { + type: 'sourceFile', + filePath: resolveKeplerReactNativeFile(moduleName), + }; + } + + if (moduleName.startsWith('react-native/')) { + return { + type: 'sourceFile', + filePath: resolveKeplerReactNativeFile(moduleName), + }; + } + + return context.resolveRequest(context, moduleName, platform); + }, + extraNodeModules: { + 'expo-iap': expoIapRoot, + 'react-native': path.join(keplerReactNativeRoot, 'index.js'), + }, + nodeModulesPaths: [ + path.resolve(__dirname, 'node_modules'), + path.resolve(expoIapRoot, 'node_modules'), + ], + }, + watchFolders: [expoIapRoot, keplerReactNativeRoot], }; - config.watchFolders = [path.resolve(__dirname, '..')]; -} + module.exports = mergeConfig( + getReactNativeDefaultConfig(__dirname), + getKeplerCompatibilityMetroConfig(), + vegaConfig, + ); +} else { + const config = getDefaultConfig(__dirname); -config.transformer.getTransformOptions = async () => ({ - transform: { - experimentalImportSupport: false, - inlineRequires: true, - }, -}); + // Exclude test files from bundling + config.resolver.blockList = [ + ...Array.from(config.resolver.blockList ?? []), + /.*\/__tests__\/.*/, + /.*\.test\.(js|jsx|ts|tsx)$/, + /.*\.spec\.(js|jsx|ts|tsx)$/, + ]; + + if (useLocalDev) { + // Local development: resolve expo-iap from parent directory source + config.resolver.blockList.push( + new RegExp(path.resolve(__dirname, '..', 'node_modules', 'react')), + new RegExp(path.resolve(__dirname, '..', 'node_modules', 'react-native')), + ); + + config.resolver.nodeModulesPaths = [ + path.resolve(__dirname, './node_modules'), + path.resolve(__dirname, '../node_modules'), + ]; + + config.resolver.extraNodeModules = { + 'expo-iap': path.resolve(__dirname, '..'), + }; -module.exports = config; + config.watchFolders = [path.resolve(__dirname, '..')]; + } + + config.transformer.getTransformOptions = async () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: true, + }, + }); + + module.exports = config; +} diff --git a/libraries/expo-iap/example/package.json b/libraries/expo-iap/example/package.json index f77674a6..d083d991 100644 --- a/libraries/expo-iap/example/package.json +++ b/libraries/expo-iap/example/package.json @@ -9,6 +9,10 @@ "ios": "expo run:ios", "tvos": "EXPO_TV=1 npx expo run:ios", "tvos:prebuild": "EXPO_TV=1 npx expo prebuild --platform ios --clean", + "vega:prebuild": "node scripts/build-vega-example.mjs Debug", + "build:vega:release": "node scripts/build-vega-example.mjs Release", + "build:vega:debug": "node scripts/build-vega-example.mjs Debug", + "run:vega:firetv": "node scripts/run-vega-firetv.mjs", "eas:preview:android": "eas build --profile preview --platform android --local", "test": "jest", "test:watch": "jest --watch", @@ -45,6 +49,8 @@ }, "devDependencies": { "@babel/core": "^7.25.2", + "@react-native-community/cli": "11.3.2", + "@react-native/metro-config": "^0.72.6", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-hooks": "^8.0.1", "@testing-library/react-native": "^12.4.5", @@ -52,8 +58,10 @@ "@types/react": "~19.1.10", "@types/react-test-renderer": "^18.3.0", "babel-jest": "^29.7.0", + "babel-plugin-module-resolver": "^5.0.2", "jest": "^29.2.1", "jest-expo": "~54.0.12", + "metro-react-native-babel-preset": "~0.76.9", "react-test-renderer": "19.1.0", "typescript": "~5.9.2" }, @@ -63,7 +71,9 @@ "nativeModulesDir": ".." }, "install": { - "exclude": ["react-native"] + "exclude": [ + "react-native" + ] } } } diff --git a/libraries/expo-iap/example/scripts/build-vega-example.mjs b/libraries/expo-iap/example/scripts/build-vega-example.mjs new file mode 100644 index 00000000..27a09e19 --- /dev/null +++ b/libraries/expo-iap/example/scripts/build-vega-example.mjs @@ -0,0 +1,487 @@ +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const exampleRoot = path.resolve(__dirname, '..'); +const packageRoot = path.resolve(exampleRoot, '..'); +const tempRoot = path.join(os.tmpdir(), 'openiap-expo-iap-vega-example'); +const tempPackageSourceRoot = path.join(tempRoot, 'openiap-expo-iap-src'); +const buildType = process.argv[2] === 'Release' ? 'Release' : 'Debug'; +const iapkitApiKey = process.env.EXPO_PUBLIC_IAPKIT_API_KEY ?? ''; +const iapkitBaseUrl = process.env.EXPO_PUBLIC_IAPKIT_BASE_URL ?? ''; +const vegaPackageId = 'dev.hyo.openiap.expo.example'; +const vegaComponentId = `${vegaPackageId}.main`; +const vegaAppName = 'ExpoIapVegaExample'; +const vegaDisplayName = 'Expo IAP Vega Example'; + +const writeFile = (relativePath, contents) => { + const filePath = path.join(tempRoot, relativePath); + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, contents, 'utf8'); +}; + +const copyFile = (source, relativeDestination) => { + const destination = path.join(tempRoot, relativeDestination); + fs.mkdirSync(path.dirname(destination), {recursive: true}); + fs.copyFileSync(source, destination); +}; + +const copyDirectory = (source, relativeDestination) => { + fs.cpSync(source, path.join(tempRoot, relativeDestination), { + recursive: true, + }); +}; + +const writeLocalPackageAlias = (packageName, entryPath) => { + const aliasRoot = path.join(tempRoot, 'node_modules', packageName); + const relativeEntry = path + .relative(aliasRoot, entryPath) + .replaceAll(path.sep, '/'); + const importPath = relativeEntry.startsWith('.') + ? relativeEntry + : `./${relativeEntry}`; + + fs.mkdirSync(aliasRoot, {recursive: true}); + fs.writeFileSync( + path.join(aliasRoot, 'package.json'), + `${JSON.stringify( + { + name: packageName, + version: '0.0.0-local', + main: 'index.ts', + 'react-native': 'index.ts', + }, + null, + 2, + )}\n`, + 'utf8', + ); + fs.writeFileSync( + path.join(aliasRoot, 'index.ts'), + `export * from ${JSON.stringify(importPath)};\n`, + 'utf8', + ); +}; + +const writeLocalEntryModule = (relativePath, entryPath) => { + const localRoot = path.dirname(path.join(tempRoot, relativePath)); + const relativeEntry = path + .relative(localRoot, entryPath) + .replaceAll(path.sep, '/'); + const importPath = relativeEntry.startsWith('.') + ? relativeEntry + : `./${relativeEntry}`; + + writeFile(relativePath, `export * from ${JSON.stringify(importPath)};\n`); +}; + +const writeLocalJavaScriptModule = (packageName, source, main = 'index.js') => { + const moduleRoot = path.join(tempRoot, 'node_modules', packageName); + fs.mkdirSync(moduleRoot, {recursive: true}); + fs.writeFileSync( + path.join(moduleRoot, 'package.json'), + `${JSON.stringify( + { + name: packageName, + version: '0.0.0-local', + main, + }, + null, + 2, + )}\n`, + 'utf8', + ); + fs.writeFileSync(path.join(moduleRoot, main), source, 'utf8'); +}; + +const createVegaManifest = () => `schema-version = 1 + +[package] +id = "${vegaPackageId}" +title = "${vegaDisplayName}" +version = "1.0.0" + +[components] +[[components.interactive]] +id = "${vegaComponentId}" +runtime-module = "/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0" +launch-type = "singleton" +categories = ["com.amazon.category.main"] + +[wants] +[[wants.service]] +id = "com.amazon.inputmethod.service" + +[[wants.service]] +id = "com.amazon.network.service" + +[[wants.service]] +id = "com.amazon.iap.core.service" + +[[wants.service]] +id = "com.amazon.iap.tester.service" + +[[wants.module]] +id = "/com.amazon.iap.core@IIAPCoreUI" + +[[wants.module]] +id = "/com.amazonappstore.iap.tester@IIAPTesterUI" + +[needs] +[[needs.module]] +id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" +`; + +const rewriteExpoSourceImports = (source) => + source + .replaceAll("from '../../src/utils/errorMapping'", "from 'expo-iap'") + .replaceAll('from "../../src/utils/errorMapping"', 'from "expo-iap"') + .replaceAll("from '../../src/types'", "from 'expo-iap'") + .replaceAll('from "../../src/types"', 'from "expo-iap"') + .replaceAll("from '../../src'", "from 'expo-iap'") + .replaceAll('from "../../src"', 'from "expo-iap"'); + +const rewritePackageSourceImports = (source, sourcePath) => { + const relativeSourcePath = path + .relative(path.join(packageRoot, 'src'), sourcePath) + .replaceAll(path.sep, '/'); + + if (relativeSourcePath === 'useIAP.ts') { + return source + .replaceAll("from './index'", "from './index.kepler'") + .replaceAll('from "./index"', 'from "./index.kepler"') + .replaceAll("from './modules/ios'", "from './index.kepler'") + .replaceAll('from "./modules/ios"', 'from "./index.kepler"') + .replaceAll("from './modules/android'", "from './index.kepler'") + .replaceAll('from "./modules/android"', 'from "./index.kepler"'); + } + + return source; +}; + +const copyDirectoryWithTransform = ( + sourceRoot, + relativeDestination, + transform, +) => { + for (const entry of fs.readdirSync(sourceRoot, {withFileTypes: true})) { + const sourcePath = path.join(sourceRoot, entry.name); + const destinationPath = path.join(relativeDestination, entry.name); + + if (entry.isDirectory()) { + copyDirectoryWithTransform(sourcePath, destinationPath, transform); + continue; + } + + if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) { + writeFile( + destinationPath, + transform(fs.readFileSync(sourcePath, 'utf8'), sourcePath), + ); + continue; + } + + copyFile(sourcePath, destinationPath); + } +}; + +const copyExampleSources = () => { + copyFile(path.join(exampleRoot, 'App.kepler.tsx'), 'App.tsx'); + copyDirectory(path.join(exampleRoot, 'src'), 'src'); + copyDirectory(path.join(exampleRoot, 'vega-shims'), 'vega-shims'); + + if (fs.existsSync(path.join(exampleRoot, 'assets'))) { + copyDirectory(path.join(exampleRoot, 'assets'), 'assets'); + } + + copyDirectoryWithTransform( + path.join(exampleRoot, 'app'), + 'app', + rewriteExpoSourceImports, + ); +}; + +const writeExampleShims = () => { + writeLocalJavaScriptModule( + 'expo-router', + `export * from '../../vega-shims/expo-router'; +`, + ); + writeLocalJavaScriptModule( + 'expo-clipboard', + `let value = ''; + +export const setStringAsync = async (nextValue) => { + value = String(nextValue ?? ''); +}; + +export const getStringAsync = async () => value; +`, + ); + writeLocalJavaScriptModule( + 'expo-constants', + `export default { + expoConfig: { + extra: { + iapkitApiKey: ${JSON.stringify(iapkitApiKey)}, + iapkitBaseUrl: ${JSON.stringify(iapkitBaseUrl)}, + }, + }, + manifest: null, +}; +`, + ); + writeLocalJavaScriptModule( + 'expo-modules-core', + `export class UnavailabilityError extends Error { + constructor(moduleName, propertyName) { + super(\`\${moduleName}.\${propertyName} is unavailable\`); + this.name = 'UnavailabilityError'; + } +} + +export class EventEmitter { + addListener() { + return {remove() {}}; + } + removeListener() {} +} + +export class EventSubscription { + remove() {} +} + +export const requireNativeModule = (moduleName) => { + throw new UnavailabilityError(moduleName, 'native module'); +}; +`, + ); + writeLocalJavaScriptModule( + '@expo/react-native-action-sheet', + `import React, {createContext, useContext} from 'react'; +import {ActionSheetIOS, Alert, Platform} from 'react-native'; + +const ActionSheetContext = createContext({ + showActionSheetWithOptions(options, callback) { + if (Platform.OS === 'ios' && ActionSheetIOS?.showActionSheetWithOptions) { + ActionSheetIOS.showActionSheetWithOptions(options, callback); + return; + } + + const labels = options?.options ?? []; + Alert.alert( + options?.title ?? 'Select option', + options?.message, + labels.map((label, index) => ({ + text: String(label), + onPress: () => callback(index), + })), + ); + }, +}); + +export const ActionSheetProvider = ({children}) => ( + + {children} + +); + +export const useActionSheet = () => useContext(ActionSheetContext); +`, + 'index.jsx', + ); +}; + +const run = (command, args, cwd = tempRoot) => { + execFileSync(command, args, { + cwd, + env: { + ...process.env, + EXPO_IAP_VEGA: '1', + }, + stdio: 'inherit', + }); +}; + +fs.rmSync(tempRoot, {force: true, recursive: true}); +fs.mkdirSync(tempRoot, {recursive: true}); +copyDirectoryWithTransform( + path.join(packageRoot, 'src'), + path.relative(tempRoot, tempPackageSourceRoot), + rewritePackageSourceImports, +); + +writeFile( + 'package.json', + `${JSON.stringify( + { + name: 'expoiapvegaexample', + version: '0.0.1', + private: true, + scripts: { + 'build:vega': `react-native build-vega --build-type ${buildType} --reset-cache`, + }, + dependencies: { + '@amazon-devices/keplerscript-appstore-iap-lib': '~2.12.13', + '@amazon-devices/react-native-kepler': '^2.0.0', + react: '18.2.0', + 'react-native': '0.72.0', + }, + devDependencies: { + '@amazon-devices/kepler-cli-platform': '~0.22.0', + '@react-native-community/cli': '11.3.2', + '@react-native/metro-config': '^0.72.6', + 'metro-react-native-babel-preset': '~0.76.9', + typescript: '4.8.4', + }, + kepler: { + projectType: 'application', + appName: vegaAppName, + targets: ['tv'], + os: ['vega'], + }, + }, + null, + 2, + )}\n`, +); + +writeFile( + 'app.json', + `${JSON.stringify( + { + '//': 'The declared app name must match the Vega component id.', + name: vegaComponentId, + displayName: vegaDisplayName, + }, + null, + 2, + )}\n`, +); + +writeFile( + 'index.js', + `import {AppRegistry} from 'react-native'; +import App from './App'; +import {name as appName} from './app.json'; + +AppRegistry.registerComponent(appName, () => App); +`, +); + +writeFile( + 'babel.config.js', + `module.exports = { + presets: ['module:metro-react-native-babel-preset'], +}; +`, +); + +writeFile( + 'metro.config.js', + `const path = require('path'); +const { + getDefaultConfig, + mergeConfig, +} = require('@react-native/metro-config'); + +const packageSourceRoot = ${JSON.stringify(tempPackageSourceRoot)}; +const tempNodeModules = path.resolve(__dirname, 'node_modules'); + +const resolveFromTemp = (moduleName) => + require.resolve(moduleName, {paths: [tempNodeModules]}); + +const vegaConfig = { + resolver: { + resolveRequest: (context, moduleName, platform) => { + if ( + moduleName === 'react' || + moduleName === 'react/jsx-runtime' || + moduleName === 'react/jsx-dev-runtime' || + moduleName === 'react-native' + ) { + return { + type: 'sourceFile', + filePath: resolveFromTemp(moduleName), + }; + } + + if (moduleName === 'expo-iap') { + return { + type: 'sourceFile', + filePath: path.join(packageSourceRoot, 'index.kepler.ts'), + }; + } + + if (moduleName === 'expo-router') { + return { + type: 'sourceFile', + filePath: path.join(__dirname, 'vega-shims', 'expo-router.tsx'), + }; + } + + return context.resolveRequest(context, moduleName, platform); + }, + disableHierarchicalLookup: true, + extraNodeModules: new Proxy( + {}, + { + get: (_target, moduleName) => + path.join(tempNodeModules, String(moduleName)), + }, + ), + nodeModulesPaths: [tempNodeModules], + }, + watchFolders: [packageSourceRoot], +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), vegaConfig); +`, +); + +copyExampleSources(); +writeLocalEntryModule( + 'openiap-expo-iap.ts', + path.join(tempPackageSourceRoot, 'index.kepler.ts'), +); +writeFile('manifest.toml', createVegaManifest()); + +run('bun', ['install', '--force']); +writeLocalPackageAlias( + 'expo-iap', + path.join(tempPackageSourceRoot, 'index.kepler.ts'), +); +writeExampleShims(); +run('./node_modules/.bin/react-native', [ + 'build-vega', + '--build-type', + buildType, + '--reset-cache', +]); + +const outputDir = path.join( + exampleRoot, + 'build', + buildType === 'Release' ? 'armv7-release' : 'armv7-debug', +); +fs.rmSync(outputDir, {force: true, recursive: true}); +fs.mkdirSync(outputDir, {recursive: true}); + +const tempOutputDir = path.join( + tempRoot, + 'build', + buildType === 'Release' ? 'armv7-release' : 'armv7-debug', +); +for (const fileName of fs.readdirSync(tempOutputDir)) { + if (fileName.endsWith('.vpkg')) { + fs.copyFileSync( + path.join(tempOutputDir, fileName), + path.join(outputDir, fileName), + ); + } +} + +console.log(`Vega ${buildType} build copied to ${outputDir}`); diff --git a/libraries/expo-iap/example/scripts/run-vega-firetv.mjs b/libraries/expo-iap/example/scripts/run-vega-firetv.mjs new file mode 100644 index 00000000..18183edb --- /dev/null +++ b/libraries/expo-iap/example/scripts/run-vega-firetv.mjs @@ -0,0 +1,270 @@ +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const exampleRoot = path.resolve(__dirname, '..'); +const packageFile = path.join( + exampleRoot, + 'build/armv7-debug/expoiapvegaexample_armv7.vpkg', +); +const packageId = 'dev.hyo.openiap.expo.example'; +const appId = 'dev.hyo.openiap.expo.example.main'; +const appTesterPackageId = 'com.amazonappstore.iap.tester'; +const appTesterUi = 'pkg://com.amazonappstore.iap.tester.ui'; +const tcpDevicePattern = /^.+:\d+$/; +const shouldLaunchAppTesterUi = process.env.VEGA_LAUNCH_APP_TESTER_UI === '1'; + +const run = (args, options = {}) => { + try { + return execFileSync('vega', args, { + cwd: exampleRoot, + encoding: options.encoding, + stdio: options.encoding ? ['ignore', 'pipe', 'pipe'] : 'inherit', + timeout: options.timeout, + }); + } catch (error) { + if (options.allowFailure) return ''; + throw error; + } +}; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const findFirstVegaDeviceId = (output) => { + for (const line of output.split(/\r?\n/)) { + const match = line.trim().match(/^(\S+)\s+:/); + if (match) return match[1]; + } + + return undefined; +}; + +const resolveDeviceId = () => { + if (process.env.VEGA_DEVICE_ID) return process.env.VEGA_DEVICE_ID; + + const deviceListOutput = run(['device', 'list'], { + allowFailure: true, + encoding: 'utf8', + }); + const vegaDeviceId = findFirstVegaDeviceId(deviceListOutput); + if (vegaDeviceId) return vegaDeviceId; + + const output = run(['exec', 'vda', 'devices'], {encoding: 'utf8'}); + const deviceLine = output + .split(/\r?\n/) + .find((line) => /\sdevice$/.test(line.trim())); + const deviceId = deviceLine?.trim().split(/\s+/)[0]; + + if (!deviceId) { + throw new Error( + 'No Vega device found. Connect a Fire TV device or set VEGA_DEVICE_ID.', + ); + } + + return deviceId; +}; + +const sleep = (milliseconds) => + new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + +const canReachTcpDevice = (deviceId) => + new Promise((resolve) => { + if (!tcpDevicePattern.test(deviceId)) { + resolve(true); + return; + } + + const [host, portText] = deviceId.split(/:(?=\d+$)/); + const socket = new net.Socket(); + let resolved = false; + const finish = (result) => { + if (resolved) return; + resolved = true; + socket.destroy(); + resolve(result); + }; + + socket.setTimeout(1500); + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.once('timeout', () => finish(false)); + socket.connect(Number(portText), host); + }); + +const isDeviceReady = (deviceId) => { + const output = run(['exec', 'vda', 'devices'], { + allowFailure: true, + encoding: 'utf8', + }); + + return output + .split(/\r?\n/) + .some((line) => line.trim() === `${deviceId}\tdevice`); +}; + +const waitForDevice = async (deviceId) => { + for (let attempt = 1; attempt <= 8; attempt += 1) { + if (isDeviceReady(deviceId)) return; + + run(['exec', 'vda', 'reconnect', 'offline'], {allowFailure: true}); + if ( + tcpDevicePattern.test(deviceId) && + (await canReachTcpDevice(deviceId)) + ) { + run(['exec', 'vda', 'disconnect', deviceId], {allowFailure: true}); + run(['exec', 'vda', 'connect', deviceId], {allowFailure: true}); + } + await sleep(2000); + } + + throw new Error( + `Vega device ${deviceId} is not connected. Confirm the Fire TV is powered on, Developer Mode is enabled, and VDA is available.`, + ); +}; + +const shell = (deviceId, args, options = {}) => { + run(['exec', 'vda', '-s', deviceId, 'shell', ...args], options); +}; + +const shellOutput = (deviceId, args) => + run(['exec', 'vda', '-s', deviceId, 'shell', ...args], { + allowFailure: true, + encoding: 'utf8', + }); + +const pushToDevice = (deviceId, source, destination) => { + run(['exec', 'vda', '-s', deviceId, 'push', source, destination]); +}; + +const copyToDevice = (deviceId, source, destinationDirectory, options = {}) => { + run([ + 'device', + 'copy-to', + '-d', + deviceId, + '-s', + source, + '-o', + destinationDirectory, + ], options); +}; + +if (!fs.existsSync(packageFile)) { + throw new Error( + `Missing ${path.relative(exampleRoot, packageFile)}. Run bun run build:vega:debug first.`, + ); +} + +const deviceId = resolveDeviceId(); +await waitForDevice(deviceId); +const appTesterCatalog = path.join(exampleRoot, 'amazon.sdktester.json'); +const appSandboxConfig = path.join(exampleRoot, 'amazon.config.json'); +const launchApp = () => { + run(['device', '-d', deviceId, 'launch-app', '--appName', appId], { + allowFailure: true, + }); +}; +const cancelQueuedInstalls = () => { + const output = shellOutput(deviceId, ['vpm', 'query-installs']); + const packageBaseName = path.basename(packageFile, '.vpkg'); + const tokenPattern = new RegExp( + `InstallRequestStatus token: (\\S*${escapeRegExp(packageBaseName)}\\S*) status: REQUEST_ENQUEUED`, + 'g', + ); + const tokens = new Set( + [...output.matchAll(tokenPattern)].map((match) => match[1]), + ); + + for (const token of tokens) { + shell(deviceId, ['vpm', 'cancel-download', token, '--timeout=5'], { + allowFailure: true, + }); + } +}; +const installApp = () => { + try { + run(['device', '-d', deviceId, 'install-app', '--packagePath', packageFile], { + timeout: 90000, + }); + return; + } catch (error) { + console.warn( + 'vega device install-app failed; retrying with vpm install-async.', + ); + } + + cancelQueuedInstalls(); + const remotePackageFile = `/tmp/${path.basename(packageFile)}`; + const token = `${path.basename(packageFile, '.vpkg')}_${Date.now()}`; + pushToDevice(deviceId, packageFile, remotePackageFile); + shell(deviceId, [ + 'vpm', + 'install-async', + remotePackageFile, + `--token=${token}`, + '--timeout=60', + '--high-priority', + '--force', + '--update-max-timeout=5', + '--terminate-on-max-timeout', + ]); + shell(deviceId, ['vpm', 'query-installs', `--token=${token}`], { + allowFailure: true, + }); +}; +const submitParentalPin = () => { + if (!process.env.VEGA_PARENTAL_PIN) return; + + for (const digit of process.env.VEGA_PARENTAL_PIN) { + shell(deviceId, ['inputd-cli', 'button_press', `KEY_${digit}`], { + allowFailure: true, + }); + } +}; + +run(['device', '-d', deviceId, 'terminate-app', '--appName', appId], { + allowFailure: true, +}); +run(['device', '-d', deviceId, 'uninstall-app', '--appName', appId], { + allowFailure: true, +}); + +shell( + deviceId, + [ + 'mkdir', + '-p', + `/tmp/scratch/${appTesterPackageId}`, + `/tmp/scratch/${packageId}`, + `/tmp/scratch/${appId}`, + ], + { + allowFailure: true, + encoding: 'utf8', + }, +); +copyToDevice(deviceId, appTesterCatalog, `/tmp/scratch/${appTesterPackageId}`); +copyToDevice(deviceId, appSandboxConfig, `/tmp/scratch/${packageId}`, { + allowFailure: true, + encoding: 'utf8', +}); +copyToDevice(deviceId, appSandboxConfig, `/tmp/scratch/${appId}`); + +shell( + deviceId, + ['vlcm', 'terminate-app', '--pkg-id', appTesterPackageId, '--force'], + { + allowFailure: true, + }, +); +if (shouldLaunchAppTesterUi) { + shell(deviceId, ['vlcm', 'launch-app', appTesterUi], {allowFailure: true}); +} +installApp(); +launchApp(); +submitParentalPin(); diff --git a/libraries/expo-iap/example/src/hooks/useVegaTvSelection.ts b/libraries/expo-iap/example/src/hooks/useVegaTvSelection.ts new file mode 100644 index 00000000..5daaf0e3 --- /dev/null +++ b/libraries/expo-iap/example/src/hooks/useVegaTvSelection.ts @@ -0,0 +1,79 @@ +import { + type Dispatch, + type SetStateAction, + useEffect, + useRef, + useState, +} from 'react'; +import {useTVEventHandler} from 'react-native'; +import { + isTvKeyRelease, + isVegaTvShortcutEnabled, + type TvRemoteEvent, +} from '../utils/vegaRuntime'; + +type UseVegaTvSelectionOptions = { + itemCount: number; + isItemDisabled?: (index: number) => boolean; + onSelect: (index: number) => void; + suppressSelection?: boolean; +}; + +export function useVegaTvSelection({ + itemCount, + isItemDisabled, + onSelect, + suppressSelection, +}: UseVegaTvSelectionOptions): { + selectedIndex: number; + setSelectedIndex: Dispatch>; +} { + const [selectedIndex, setSelectedIndex] = useState(0); + const suppressUntilRef = useRef(0); + + useEffect(() => { + setSelectedIndex((currentIndex) => + Math.min(currentIndex, Math.max(itemCount - 1, 0)), + ); + }, [itemCount]); + + useEffect(() => { + if (suppressSelection) { + suppressUntilRef.current = Date.now() + 1_500; + } + }, [suppressSelection]); + + useTVEventHandler((event: TvRemoteEvent) => { + if (!isVegaTvShortcutEnabled() || !isTvKeyRelease(event)) { + return; + } + + if (event.eventType === 'down' || event.eventType === 'right') { + setSelectedIndex((currentIndex) => + Math.min(currentIndex + 1, Math.max(itemCount - 1, 0)), + ); + return; + } + + if (event.eventType === 'up' || event.eventType === 'left') { + setSelectedIndex((currentIndex) => Math.max(currentIndex - 1, 0)); + return; + } + + if (event.eventType !== 'select' && event.eventType !== 'enter') { + return; + } + + if (Date.now() < suppressUntilRef.current) { + return; + } + + if (selectedIndex >= itemCount || isItemDisabled?.(selectedIndex)) { + return; + } + + onSelect(selectedIndex); + }); + + return {selectedIndex, setSelectedIndex}; +} diff --git a/libraries/expo-iap/example/src/utils/errorUtils.ts b/libraries/expo-iap/example/src/utils/errorUtils.ts index 30c14eda..a2ac5e43 100644 --- a/libraries/expo-iap/example/src/utils/errorUtils.ts +++ b/libraries/expo-iap/example/src/utils/errorUtils.ts @@ -7,6 +7,14 @@ export function extractErrorMessage(error: unknown): string { return error.message; } + if ( + typeof error === 'string' || + typeof error === 'number' || + typeof error === 'boolean' + ) { + return String(error); + } + if ( error && typeof error === 'object' && @@ -17,5 +25,9 @@ export function extractErrorMessage(error: unknown): string { return errors[0]?.message || JSON.stringify(errors[0]) || 'Unknown error'; } - return 'Unknown error'; + if (error && typeof error === 'object' && 'message' in error) { + return String((error as {message: unknown}).message); + } + + return String(error ?? 'Unknown error'); } diff --git a/libraries/expo-iap/example/src/utils/vegaRuntime.ts b/libraries/expo-iap/example/src/utils/vegaRuntime.ts new file mode 100644 index 00000000..9678d8cb --- /dev/null +++ b/libraries/expo-iap/example/src/utils/vegaRuntime.ts @@ -0,0 +1,125 @@ +import {Alert, Platform} from 'react-native'; +import Constants from 'expo-constants'; +import type { + Purchase, + VerifyPurchaseWithProviderProps, +} from '../../../src/types'; + +export type IapkitVerificationPayload = NonNullable< + VerifyPurchaseWithProviderProps['iapkit'] +> & { + baseUrl?: string | null; +}; + +type ExpoExtraWithIapkit = { + iapkitApiKey?: string; + iapkitBaseUrl?: string; +}; + +export type VerificationMethod = 'ignore' | 'local' | 'iapkit'; + +function getConfiguredIapkitApiKey(): string | undefined { + const extra = Constants.expoConfig?.extra as ExpoExtraWithIapkit | undefined; + return extra?.iapkitApiKey ?? process.env.EXPO_PUBLIC_IAPKIT_API_KEY; +} + +function getConfiguredIapkitBaseUrl(): string | undefined { + const extra = Constants.expoConfig?.extra as ExpoExtraWithIapkit | undefined; + return extra?.iapkitBaseUrl ?? process.env.EXPO_PUBLIC_IAPKIT_BASE_URL; +} + +export function getDefaultVerificationMethod(): VerificationMethod { + return getConfiguredIapkitApiKey()?.trim() ? 'iapkit' : 'ignore'; +} + +function withIapkitEndpoint( + payload: IapkitVerificationPayload, + baseUrl?: string | null, +): IapkitVerificationPayload { + const trimmedBaseUrl = baseUrl?.trim(); + if (!trimmedBaseUrl) { + return payload; + } + return { + ...payload, + baseUrl: trimmedBaseUrl, + }; +} + +export type TvRemoteEvent = { + eventKeyAction?: number; + eventType?: string; +}; + +export function isVegaTvShortcutEnabled(): boolean { + return Boolean( + (globalThis as {EXPO_IAP_ENABLE_TV_SHORTCUTS?: boolean}) + .EXPO_IAP_ENABLE_TV_SHORTCUTS, + ); +} + +export function isTvKeyRelease(event: TvRemoteEvent): boolean { + return event.eventKeyAction === undefined || event.eventKeyAction === 1; +} + +export function showNativeAlert(title: string, message?: string): void { + const shouldSuppressAlerts = Boolean( + (globalThis as {EXPO_IAP_SUPPRESS_NATIVE_ALERTS?: boolean}) + .EXPO_IAP_SUPPRESS_NATIVE_ALERTS, + ); + if (!shouldSuppressAlerts) { + Alert.alert(title, message); + } +} + +export function createIapkitVerificationPayload( + purchase: Purchase, + purchaseToken: string, + baseUrl: string | null | undefined = getConfiguredIapkitBaseUrl(), +): IapkitVerificationPayload { + const apiKey = getConfiguredIapkitApiKey()?.trim(); + const purchaseStore = ( + (purchase as Purchase & {store?: string | null}).store ?? '' + ).toLowerCase(); + if (purchaseStore === 'amazon') { + return withIapkitEndpoint( + { + ...(apiKey ? {apiKey} : {}), + amazon: { + receiptId: purchaseToken, + sandbox: __DEV__, + }, + }, + baseUrl, + ); + } + + const isApplePurchase = + purchaseStore === 'apple' || (!purchaseStore && Platform.OS === 'ios'); + + return withIapkitEndpoint( + isApplePurchase + ? { + ...(apiKey ? {apiKey} : {}), + apple: { + jws: purchaseToken, + }, + } + : { + ...(apiKey ? {apiKey} : {}), + google: { + purchaseToken, + }, + }, + baseUrl, + ); +} + +export function getPurchaseCleanupKey(purchase: Purchase): string { + return ( + purchase.purchaseToken ?? + purchase.id ?? + purchase.productId ?? + `${purchase.transactionDate ?? Date.now()}` + ); +} diff --git a/libraries/expo-iap/example/vega-shims/expo-router.tsx b/libraries/expo-iap/example/vega-shims/expo-router.tsx new file mode 100644 index 00000000..9fb99e09 --- /dev/null +++ b/libraries/expo-iap/example/vega-shims/expo-router.tsx @@ -0,0 +1,93 @@ +import React, {createContext, useContext} from 'react'; +import {TouchableOpacity} from 'react-native'; + +type RouterHref = string | {pathname?: string}; + +interface RouterShimNavigation { + navigate(href: RouterHref): void; + replace(href: RouterHref): void; + back(): void; +} + +interface RouterShimProviderProps { + children: React.ReactNode; + navigation: RouterShimNavigation; +} + +interface LinkProps { + asChild?: boolean; + children: React.ReactNode; + href: RouterHref; +} + +interface StackProps { + children?: React.ReactNode; +} + +const inertNavigation: RouterShimNavigation = { + navigate() {}, + replace() {}, + back() {}, +}; + +const RouterShimContext = + createContext(inertNavigation); + +export function ExpoRouterShimProvider({ + children, + navigation, +}: RouterShimProviderProps): React.JSX.Element { + return ( + + {children} + + ); +} + +const normalizeHref = (href: RouterHref): string => { + if (typeof href === 'string') return href; + return href.pathname ?? '/'; +}; + +export function Link({ + asChild, + children, + href, +}: LinkProps): React.JSX.Element { + const navigation = useContext(RouterShimContext); + + const handlePress = () => { + navigation.navigate(normalizeHref(href)); + }; + + if (asChild && React.isValidElement(children)) { + const child = children as React.ReactElement<{onPress?: () => void}>; + return React.cloneElement(child, { + onPress: () => { + child.props.onPress?.(); + handlePress(); + }, + }); + } + + return {children}; +} + +export function Slot(): null { + return null; +} + +function StackComponent({children}: StackProps): React.JSX.Element { + return <>{children}; +} + +StackComponent.Screen = function Screen(): null { + return null; +}; + +export const Stack = StackComponent; + +export const router: RouterShimNavigation = inertNavigation; + +export const useRouter = (): RouterShimNavigation => + useContext(RouterShimContext); diff --git a/libraries/expo-iap/ios/ExpoIapLog.swift b/libraries/expo-iap/ios/ExpoIapLog.swift index 1a63bc51..e5e21a94 100644 --- a/libraries/expo-iap/ios/ExpoIapLog.swift +++ b/libraries/expo-iap/ios/ExpoIapLog.swift @@ -4,6 +4,22 @@ import os #endif enum ExpoIapLog { + private static let sensitiveKeyFragments: Set = [ + "token", + "apikey", + "secret", + "jws", + "receiptid", + "userid", + "password", + "bearer", + ] + private static let sensitiveAuthKeys: Set = [ + "auth", + "authorization", + "authheader", + ] + enum Level: String { case debug case info @@ -114,9 +130,16 @@ enum ExpoIapLog { } private static func sanitizeDictionary(_ dictionary: [String: Any]) -> [String: Any] { + func isSensitiveKey(_ key: String) -> Bool { + let normalized = key.lowercased() + .filter { $0.isLetter || $0.isNumber } + return sensitiveKeyFragments.contains { normalized.contains($0) } || + sensitiveAuthKeys.contains(normalized) + } + var sanitized: [String: Any] = [:] for (key, value) in dictionary { - if key.lowercased().contains("token") { + if isSensitiveKey(key) { sanitized[key] = "hidden" } else if let sanitizedValue = sanitize(value) { sanitized[key] = sanitizedValue diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index c6e7a2ec..cbb75665 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -240,7 +240,13 @@ public final class ExpoIapModule: Module { } AsyncFunction("verifyPurchaseWithProvider") { (params: [String: Any]) async throws -> [String: Any] in - ExpoIapLog.payload("verifyPurchaseWithProvider", payload: params) + ExpoIapLog.payload( + "verifyPurchaseWithProvider", + payload: [ + "provider": params["provider"] ?? "unknown", + "hasIapkit": params["iapkit"] != nil + ] + ) do { let jsonData = try JSONSerialization.data(withJSONObject: params) let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: jsonData) diff --git a/libraries/expo-iap/ios/onside/OnsideIapModule.swift b/libraries/expo-iap/ios/onside/OnsideIapModule.swift index cf6f7d78..e42a389b 100644 --- a/libraries/expo-iap/ios/onside/OnsideIapModule.swift +++ b/libraries/expo-iap/ios/onside/OnsideIapModule.swift @@ -50,6 +50,7 @@ public final class ExpoIapOnsideModule: Module { private let transactionObserver = OnsideTransactionObserverBridge() private let productFetcher = OnsideProductFetcher() private var productCache: [String: OnsideProduct] = [:] + private var transactionDateCache: [String: Date] = [:] nonisolated public func definition() -> ModuleDefinition { Name("ExpoIapOnside") @@ -112,8 +113,16 @@ public final class ExpoIapOnsideModule: Module { #endif // Check if Onside Store is installed - if let onsideURL = URL(string: "onside://"), - UIApplication.shared.canOpenURL(onsideURL) { + let canOpenOnsideStore: Bool + if let onsideURL = URL(string: "onside://") { + canOpenOnsideStore = await MainActor.run { + UIApplication.shared.canOpenURL(onsideURL) + } + } else { + canOpenOnsideStore = false + } + + if canOpenOnsideStore { #if DEBUG print("[ExpoIapOnsideModule] ✅ Onside Store app is installed") #endif @@ -225,9 +234,8 @@ public final class ExpoIapOnsideModule: Module { let productId = purchasePayload["productId"] as? String let txId = purchasePayload["transactionId"] as? String - let queue = await Onside.defaultPaymentQueue() - let transaction: OnsidePaymentTransaction? = await MainActor.run { + let queue = Onside.defaultPaymentQueue() if let txId, !txId.isEmpty { return queue.transactions.first(where: { $0.transactionIdentifier == txId }) } @@ -247,7 +255,9 @@ public final class ExpoIapOnsideModule: Module { throw OnsideBridgeError.transactionNotFound(txId ?? productId ?? "") } - await queue.finishTransaction(transaction) + await MainActor.run { + Onside.defaultPaymentQueue().finishTransaction(transaction) + } ExpoIapLog.result("finishTransactionOnside", value: true) return true } @@ -298,13 +308,15 @@ public final class ExpoIapOnsideModule: Module { ] ) try await ensureObserverRegistered() - let queue = await Onside.defaultPaymentQueue() - let payload = try queue.transactions.compactMap { transaction -> [String: Any]? in - switch transaction.transactionState { - case .purchased, .restored: - return try serialize(transaction: transaction) - default: - return nil + let payload = try await MainActor.run { + let queue = Onside.defaultPaymentQueue() + return try queue.transactions.compactMap { transaction -> [String: Any]? in + switch transaction.transactionState { + case .purchased, .restored: + return try serialize(transaction: transaction) + default: + return nil + } } } ExpoIapLog.result("getAvailableItemsOnside", value: payload) @@ -323,7 +335,7 @@ public final class ExpoIapOnsideModule: Module { private func getOnsideStorefront() async throws -> String { ExpoIapLog.payload("getStorefrontOnside", payload: nil) try await ensureObserverRegistered() - let storefront = await Onside.defaultPaymentQueue().storefront?.countryCode ?? "" + let storefront = Onside.defaultPaymentQueue().storefront?.countryCode ?? "" ExpoIapLog.result("getStorefrontOnside", value: storefront) return storefront } @@ -382,6 +394,8 @@ public final class ExpoIapOnsideModule: Module { Onside.defaultPaymentQueue().remove(observer: transactionObserver) isInitialized = false } + productCache.removeAll() + transactionDateCache.removeAll() let cont = restoreContinuation restoreContinuation = nil cont?.resume(returning: false) @@ -425,11 +439,11 @@ public final class ExpoIapOnsideModule: Module { dictionary["displayNameIOS"] = product.localizedTitle let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = product.price.currencyCode ?? "" - let priceNumber = NSDecimalNumber(decimal: product.price.value) + formatter.currencyCode = product.price.currencyCode + let priceNumber = makePriceNumber(from: product) let formattedPrice = formatter.string(from: priceNumber) ?? "\(product.price.value)" dictionary["displayPrice"] = formattedPrice - dictionary["currency"] = product.price.currencyCode ?? "" + dictionary["currency"] = product.price.currencyCode dictionary["price"] = priceNumber dictionary["type"] = "in-app" dictionary["typeIOS"] = "non-consumable" @@ -450,15 +464,15 @@ public final class ExpoIapOnsideModule: Module { dictionary["quantity"] = 1 dictionary["isAutoRenewing"] = false dictionary["purchaseState"] = mapPurchaseState(transaction.transactionState) - let txDate = transaction.transactionDate ?? Date() + let txDate = fallbackTransactionDate(for: transaction) dictionary["transactionDate"] = Int(txDate.timeIntervalSince1970 * 1000) - dictionary["currencyCodeIOS"] = product.price.currencyCode ?? "" + dictionary["currencyCodeIOS"] = product.price.currencyCode let currencyFormatter = NumberFormatter() currencyFormatter.numberStyle = .currency - currencyFormatter.currencyCode = product.price.currencyCode ?? "" + currencyFormatter.currencyCode = product.price.currencyCode dictionary["currencySymbolIOS"] = currencyFormatter.currencySymbol ?? "" - dictionary["storefrontCountryCodeIOS"] = transaction.storefront.countryCode ?? "" + dictionary["storefrontCountryCodeIOS"] = transaction.storefront.countryCode dictionary["purchaseToken"] = nil dictionary["environmentIOS"] = transaction.storefront.id if let error = transaction.error { @@ -471,8 +485,8 @@ public final class ExpoIapOnsideModule: Module { private func makeProductJSONRepresentation(from product: OnsideProduct) throws -> String { let priceFormatter = NumberFormatter() priceFormatter.numberStyle = .currency - priceFormatter.currencyCode = product.price.currencyCode ?? "" - let priceNumber = NSDecimalNumber(decimal: product.price.value) + priceFormatter.currencyCode = product.price.currencyCode + let priceNumber = makePriceNumber(from: product) let formattedPrice = priceFormatter.string(from: priceNumber) ?? "\(product.price.value)" let jsonObject: [String: Any] = [ "id": product.productIdentifier, @@ -480,7 +494,7 @@ public final class ExpoIapOnsideModule: Module { "description": product.localizedDescription, "price": [ "value": priceNumber, - "currencyCode": product.price.currencyCode ?? "", + "currencyCode": product.price.currencyCode, "formatted": formattedPrice, ], "isFamilyShareable": false, @@ -494,6 +508,22 @@ public final class ExpoIapOnsideModule: Module { return json } + private func makePriceNumber(from product: OnsideProduct) -> NSDecimalNumber { + NSDecimalNumber(decimal: Decimal(product.price.value)) + } + + private func fallbackTransactionDate(for transaction: OnsidePaymentTransaction) -> Date { + let cacheKey = transaction.transactionIdentifier + ?? transaction.payment.product.productIdentifier + if let cachedDate = transactionDateCache[cacheKey] { + return cachedDate + } + + let date = Date() + transactionDateCache[cacheKey] = date + return date + } + private func sanitize(_ dictionary: [String: Any?]) -> [String: Any] { var result: [String: Any] = [:] for (key, value) in dictionary { @@ -655,6 +685,7 @@ private final class OnsideProductFetcher: NSObject, OnsideProductsRequestDelegat } } + @MainActor private func cleanup() { request?.delegate = nil request?.stop() diff --git a/libraries/expo-iap/package.json b/libraries/expo-iap/package.json index 028576e8..90e97357 100644 --- a/libraries/expo-iap/package.json +++ b/libraries/expo-iap/package.json @@ -1,6 +1,6 @@ { "name": "expo-iap", - "version": "4.3.1", + "version": "4.4.0-rc.5", "description": "In App Purchase module in Expo", "main": "build/index.js", "types": "build/index.d.ts", @@ -65,10 +65,20 @@ "ts-jest": "^29.4.1" }, "peerDependencies": { + "@amazon-devices/keplerscript-appstore-iap-lib": "~2.12.13", + "@amazon-devices/package-manager-lib": "~1.0.1767254401", "expo": "*", "react": "*", "react-native": "*" }, + "peerDependenciesMeta": { + "@amazon-devices/keplerscript-appstore-iap-lib": { + "optional": true + }, + "@amazon-devices/package-manager-lib": { + "optional": true + } + }, "expo": { "plugin": "./app.plugin.js" } diff --git a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts index 65166117..505a5d4f 100644 --- a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts +++ b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts @@ -1,18 +1,36 @@ import type {ExpoConfig} from '@expo/config-types'; -import { +import plugin, { computeAutolinkModules, ensureOnsidePodIOS, modifyAppBuildGradle, + normalizeGeneratedGroovyAppBuildGradle, + normalizeGeneratedGroovyProjectBuildGradle, + resolveAmazonPlatformFlags, resolveModuleSelection, + syncHorizonAppIdMetaData, } from '../src/withIAP'; +import {getAndroidLocalPathInput} from '../src/withLocalOpenIAP'; import type {AutolinkState} from '../src/withIAP'; import type {ExpoIapPluginCommonOptions} from '../src/expoConfig.augmentation'; +import { + createVegaAppJson, + createVegaEntryPoint, + createVegaManifest, + mergeVegaPackageJson, + normalizeVegaPackageId, + resolveVegaProjectSettings, +} from '../src/withVega'; // Type-level expectations const autoModeOptions: ExpoIapPluginCommonOptions = { + amazon: {fireOS: false, vegaOS: false}, modules: {onside: true}, }; +const groupedAmazonOptions: ExpoIapPluginCommonOptions = { + amazon: {fireOS: true, vegaOS: true}, +}; + const explicitModeOptions: ExpoIapPluginCommonOptions = { module: 'onside', }; @@ -21,6 +39,7 @@ const invalidExplicitOptions: ExpoIapPluginCommonOptions = { modules: {onside: false}, }; void autoModeOptions; +void groupedAmazonOptions; void explicitModeOptions; void invalidExplicitOptions; @@ -57,6 +76,276 @@ describe('android configuration', () => { 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', () => { + const baseGradle = [ + 'android {', + ' defaultConfig {', + ' }', + '}', + 'dependencies {', + ' implementation "io.github.hyochan.openiap:openiap-google-horizon:0.0.1"', + '}', + '', + ].join('\n'); + const result = modifyAppBuildGradle(baseGradle, 'groovy', false, true); + + 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'); + }); + + it('prefers Fire OS over Horizon when both store flags are enabled', () => { + const baseGradle = [ + 'android {', + ' defaultConfig {', + ' }', + '}', + 'dependencies {', + '}', + '', + ].join('\n'); + const result = modifyAppBuildGradle(baseGradle, 'kotlin', true, true); + + expect(result).toContain( + ` implementation("io.github.hyochan.openiap:openiap-google-amazon:${dependencyVersion}")`, + ); + expect(result).toContain( + ' missingDimensionStrategy("platform", "amazon")', + ); + }); + + 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'); + + expect(result).toContain( + ` implementation "io.github.hyochan.openiap:openiap-google:${dependencyVersion}"`, + ); + expect(result).not.toContain('openiap-google-amazon:0.0.1'); + expect(result).toContain('missingDimensionStrategy "platform", "play"'); + }); + + it('normalizes Expo generated Groovy root Gradle syntax', () => { + const result = normalizeGeneratedGroovyProjectBuildGradle( + "allprojects {\n repositories {\n maven { url 'https://www.jitpack.io' }\n }\n}\n", + ); + + expect(result).toContain("maven { url = uri('https://www.jitpack.io') }"); + expect(result).not.toContain("url 'https://www.jitpack.io'"); + }); + + it('normalizes Expo generated Groovy app Gradle assignment syntax', () => { + const result = normalizeGeneratedGroovyAppBuildGradle( + [ + 'android {', + ' ndkVersion rootProject.ext.ndkVersion', + ' buildToolsVersion rootProject.ext.buildToolsVersion', + ' compileSdk rootProject.ext.compileSdkVersion', + " namespace 'dev.hyo.openiap.expo.example'", + ' defaultConfig {', + " applicationId 'dev.hyo.openiap.expo.example'", + ' minSdkVersion rootProject.ext.minSdkVersion', + ' targetSdkVersion rootProject.ext.targetSdkVersion', + ' }', + ' buildTypes {', + ' debug {', + ' signingConfig signingConfigs.debug', + ' }', + ' release {', + ' shrinkResources enableShrinkResources.toBoolean()', + ' crunchPngs enablePngCrunchInRelease.toBoolean()', + ' }', + ' }', + ' packagingOptions {', + ' jniLibs {', + ' useLegacyPackaging enableLegacyPackaging.toBoolean()', + ' }', + ' }', + ' androidResources {', + " ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'", + ' }', + '}', + ].join('\n'), + ); + + expect(result).toContain('ndkVersion = rootProject.ext.ndkVersion'); + expect(result).toContain('compileSdk = rootProject.ext.compileSdkVersion'); + expect(result).toContain("namespace = 'dev.hyo.openiap.expo.example'"); + expect(result).toContain('minSdk = rootProject.ext.minSdkVersion'); + expect(result).toContain('targetSdk = rootProject.ext.targetSdkVersion'); + expect(result).toContain('signingConfig = signingConfigs.debug'); + expect(result).toContain( + 'shrinkResources = enableShrinkResources.toBoolean()', + ); + expect(result).toContain( + 'crunchPngs = enablePngCrunchInRelease.toBoolean()', + ); + expect(result).toContain( + 'useLegacyPackaging = enableLegacyPackaging.toBoolean()', + ); + expect(result).toContain("ignoreAssetsPattern = '!.svn"); + expect(result).not.toContain( + 'compileSdk rootProject.ext.compileSdkVersion', + ); + expect(result).not.toContain('minSdkVersion rootProject.ext.minSdkVersion'); + }); + + it('allows Fire OS and Vega OS to be enabled as Amazon targets', () => { + expect( + resolveAmazonPlatformFlags({ + amazon: {fireOS: true, vegaOS: true}, + }), + ).toEqual({ + isFireOsEnabled: true, + isVegaEnabled: true, + isHorizonEnabled: false, + isOnsideEnabled: false, + }); + }); + + it('keeps Onside and Horizon selection outside the Amazon group', () => { + expect( + resolveAmazonPlatformFlags({ + modules: {horizon: true, onside: true}, + }), + ).toEqual({ + isFireOsEnabled: false, + isVegaEnabled: false, + isHorizonEnabled: true, + isOnsideEnabled: true, + }); + }); + + it('lets Fire OS take precedence over Horizon for Android flavor selection', () => { + expect( + resolveAmazonPlatformFlags({ + amazon: {fireOS: true, vegaOS: false}, + modules: {horizon: true}, + }), + ).toEqual({ + isFireOsEnabled: true, + isVegaEnabled: false, + isHorizonEnabled: false, + isOnsideEnabled: false, + }); + }); + + it('removes Horizon App ID metadata outside Horizon builds', () => { + const manifest = { + manifest: { + application: [ + { + 'meta-data': [ + { + $: { + 'android:name': 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'android:value': '123', + }, + }, + { + $: { + 'android:name': 'dev.iapkit.API_KEY', + 'android:value': 'key', + }, + }, + ], + }, + ], + }, + }; + + expect(syncHorizonAppIdMetaData(manifest, false, '123')).toBe('removed'); + expect(manifest.manifest.application[0]!['meta-data']).toEqual([ + { + $: { + 'android:name': 'dev.iapkit.API_KEY', + 'android:value': 'key', + }, + }, + ]); + }); + + it('adds Horizon App ID metadata only for Horizon builds', () => { + const manifest = {manifest: {}}; + + expect(syncHorizonAppIdMetaData(manifest, false, '123')).toBe('unchanged'); + expect(manifest.manifest).not.toHaveProperty('application'); + + expect(syncHorizonAppIdMetaData(manifest, true, '123')).toBe('added'); + expect(manifest.manifest.application?.[0]?.['meta-data']).toEqual([ + { + $: { + 'android:name': 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'android:value': '123', + }, + }, + ]); + }); + + it('normalizes a single meta-data object before adding Horizon metadata', () => { + const manifest = { + manifest: { + application: [ + { + 'meta-data': { + $: { + 'android:name': 'dev.iapkit.API_KEY', + 'android:value': 'key', + }, + }, + }, + ], + }, + }; + + expect(syncHorizonAppIdMetaData(manifest, true, '123')).toBe('added'); + expect(manifest.manifest.application[0]!['meta-data']).toEqual([ + { + $: { + 'android:name': 'dev.iapkit.API_KEY', + 'android:value': 'key', + }, + }, + { + $: { + 'android:name': 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'android:value': '123', + }, + }, + ]); + }); +}); + +describe('local OpenIAP configuration', () => { + it('uses string localPath for Android local module resolution', () => { + expect(getAndroidLocalPathInput('/repo/packages/google')).toBe( + '/repo/packages/google', + ); + }); + + it('uses android localPath when platform paths are split', () => { + expect( + getAndroidLocalPathInput({ + ios: '/repo/packages/apple', + android: '/repo/packages/google', + }), + ).toBe('/repo/packages/google'); + }); }); describe('ios module selection', () => { @@ -237,3 +526,151 @@ describe('ensureOnsidePodIOS', () => { expect(content).toContain("ENV['EXPO_IAP_ONSIDE'] = '1'"); }); }); + +describe('vega project generation', () => { + it('normalizes derived Vega package ids', () => { + expect(normalizeVegaPackageId('dev.hyo.martie')).toBe('dev.hyo.martie'); + expect(normalizeVegaPackageId('123 bad id')).toBe('app_123.bad.id'); + }); + + it('creates a manifest from Expo config defaults', () => { + const settings = resolveVegaProjectSettings({ + name: 'Expo IAP Example', + slug: 'expo-iap-example', + version: '1.0.0', + icon: './assets/images/icon.png', + android: {package: 'dev.hyo.martie'}, + } as ExpoConfig); + const manifest = createVegaManifest(settings); + + expect(settings.packageId).toBe('dev.hyo.martie'); + expect(settings.componentId).toBe('dev.hyo.martie.main'); + expect(settings.appName).toBe('ExpoIAPExample'); + expect(manifest).toContain('id = "dev.hyo.martie"'); + expect(manifest).toContain('icon = "@image/icon.png"'); + expect(manifest).toContain('id = "com.amazon.iap.core.service"'); + expect(manifest).toContain( + 'id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService"', + ); + expect(createVegaEntryPoint()).toContain( + 'AppRegistry.registerComponent(appName, () => App);', + ); + expect(createVegaAppJson(settings)).toEqual({ + name: 'ExpoIAPExample', + displayName: 'Expo IAP Example', + expoIapGenerated: true, + }); + }); + + it('merges Vega scripts, dependency buckets, and kepler metadata', () => { + const settings = resolveVegaProjectSettings({ + name: 'Expo IAP Example', + slug: 'expo-iap-example', + android: {package: 'dev.hyo.martie'}, + } as ExpoConfig); + const result = mergeVegaPackageJson( + { + scripts: {start: 'expo start'}, + dependencies: {expo: '^54.0.0'}, + devDependencies: {typescript: '~5.9.2'}, + }, + settings, + ); + + expect(result.scripts?.start).toBe('expo start'); + expect(result.scripts?.['vega:prebuild']).toContain('expo prebuild'); + expect(result.scripts?.['build:vega:release']).toContain('expo prebuild'); + expect(result.scripts?.['build:vega:release']).toContain('build-vega'); + expect(result.scripts?.['run:vega:firetv']).toContain('armv7-debug'); + expect(result.scripts?.['run:vega:firetv']).toContain( + 'vega device install-app', + ); + expect(result.scripts?.['run:vega:firetv']).toContain( + 'vega device launch-app', + ); + expect(result.dependencies?.expo).toBe('^54.0.0'); + expect( + result.dependencies?.['@amazon-devices/keplerscript-appstore-iap-lib'], + ).toBe('~2.12.13'); + expect( + result.devDependencies?.['@amazon-devices/kepler-cli-platform'], + ).toBe('~0.22.0'); + expect( + result.devDependencies?.['@amazon-devices/react-native-kepler'], + ).toBeUndefined(); + expect( + result.optionalDependencies?.['@amazon-devices/react-native-kepler'], + ).toBe('^2.0.0'); + expect(result.kepler?.appName).toBe('ExpoIAPExample'); + }); + + it('moves existing react-native-kepler direct dependency into optionalDependencies', () => { + const settings = resolveVegaProjectSettings({ + name: 'Expo IAP Example', + slug: 'expo-iap-example', + android: {package: 'dev.hyo.martie'}, + } as ExpoConfig); + const result = mergeVegaPackageJson( + { + dependencies: { + '@amazon-devices/keplerscript-appstore-iap-lib': '~2.12.13', + }, + devDependencies: { + '@amazon-devices/kepler-cli-platform': '~0.22.0', + '@amazon-devices/react-native-kepler': '^2.0.0', + }, + }, + settings, + ); + + expect( + result.dependencies?.['@amazon-devices/keplerscript-appstore-iap-lib'], + ).toBe('~2.12.13'); + expect( + result.devDependencies?.['@amazon-devices/kepler-cli-platform'], + ).toBe('~0.22.0'); + expect( + result.devDependencies?.['@amazon-devices/react-native-kepler'], + ).toBeUndefined(); + expect( + result.optionalDependencies?.['@amazon-devices/react-native-kepler'], + ).toBe('^2.0.0'); + }); + + it('moves Vega CLI tooling out of optionalDependencies for command discovery', () => { + const settings = resolveVegaProjectSettings({ + name: 'Expo IAP Example', + slug: 'expo-iap-example', + android: {package: 'dev.hyo.martie'}, + } as ExpoConfig); + const result = mergeVegaPackageJson( + { + optionalDependencies: { + '@amazon-devices/kepler-cli-platform': '~0.22.0', + '@amazon-devices/kepler-compatibility-metro-config': '^0.0.6', + '@amazon-devices/kepler-module-resolver-preset': '^0.1.15', + '@amazon-devices/react-native-kepler': '^2.0.0', + }, + }, + settings, + ); + + expect( + result.devDependencies?.['@amazon-devices/kepler-cli-platform'], + ).toBe('~0.22.0'); + expect( + result.devDependencies?.[ + '@amazon-devices/kepler-compatibility-metro-config' + ], + ).toBe('^0.0.6'); + expect( + result.devDependencies?.['@amazon-devices/kepler-module-resolver-preset'], + ).toBe('^0.1.15'); + expect( + result.optionalDependencies?.['@amazon-devices/kepler-cli-platform'], + ).toBeUndefined(); + expect( + result.optionalDependencies?.['@amazon-devices/react-native-kepler'], + ).toBe('^2.0.0'); + }); +}); diff --git a/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts b/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts index 28eb6132..f7853267 100644 --- a/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts +++ b/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts @@ -1,5 +1,6 @@ import type {IOS} from '@expo/config-types'; import type {IOSAlternativeBillingConfig} from './withIAP'; +import type {VegaProjectOptions} from './withVega'; export type ExpoIapModuleOverrides = { /** @@ -16,6 +17,23 @@ export type ExpoIapModuleOverrides = { horizon?: boolean; }; +export type AmazonPlatformOptions = { + /** + * Enable Fire OS support for Amazon-distributed Android builds. + * This selects the Android `amazon` flavor. + * @platform android + * @default false + */ + fireOS?: boolean; + /** + * Enable Vega OS project generation for Amazon's Kepler runtime. + * This prepares Vega metadata and build scripts; it does not select an + * Android Gradle flavor. + * @default false + */ + vegaOS?: boolean; +}; + type BaseExpoIapOptions = { enableLocalDev?: boolean; localPath?: @@ -29,25 +47,34 @@ type BaseExpoIapOptions = { * Configure external purchase countries, links, and entitlements. * Requires approval from Apple. * @platform ios - */ - iosAlternativeBilling?: IOSAlternativeBillingConfig; - /** * @deprecated Use ios.alternativeBilling instead */ + iosAlternativeBilling?: IOSAlternativeBillingConfig; ios?: { alternativeBilling?: IOSAlternativeBillingConfig; }; /** * Horizon OS app ID for Quest devices * @platform android + * @deprecated Use android.horizonAppId instead */ horizonAppId?: string; - /** - * @deprecated Use modules.horizon and android.horizonAppId instead - */ android?: { + /** + * Horizon OS app ID for Quest devices + * @platform android + */ horizonAppId?: string; }; + /** + * Amazon platform targets. Fire OS and Vega OS can both be enabled in the + * same config, but they still produce separate build artifacts. + */ + amazon?: AmazonPlatformOptions; + /** + * Vega project generation options used when amazon.vegaOS is true. + */ + vega?: VegaProjectOptions; }; type AutoModuleOptions = BaseExpoIapOptions & { diff --git a/libraries/expo-iap/plugin/src/withIAP.ts b/libraries/expo-iap/plugin/src/withIAP.ts index 6bc2c71d..9abc3ea8 100644 --- a/libraries/expo-iap/plugin/src/withIAP.ts +++ b/libraries/expo-iap/plugin/src/withIAP.ts @@ -7,25 +7,23 @@ import { withGradleProperties, withInfoPlist, withPodfile, + withProjectBuildGradle, } from 'expo/config-plugins'; import type {ExpoConfig} from '@expo/config-types'; import * as fs from 'fs'; import * as path from 'path'; import withLocalOpenIAP from './withLocalOpenIAP'; +import withVega, {type VegaProjectOptions} from './withVega'; import { withIosAlternativeBilling, type IOSAlternativeBillingConfig, } from './withIosAlternativeBilling'; -import type {ExpoIapPluginCommonOptions} from './expoConfig.augmentation'; +import type { + AmazonPlatformOptions, + ExpoIapPluginCommonOptions, +} from './expoConfig.augmentation'; const pkg = require('../../package.json'); -const openiapVersions = JSON.parse( - fs.readFileSync( - path.resolve(__dirname, '../../openiap-versions.json'), - 'utf8', - ), -); -const OPENIAP_ANDROID_VERSION = openiapVersions.google; const AUTOLINKING_CONFIG_PATH = path.resolve( __dirname, '../../expo-module.config.json', @@ -62,18 +60,195 @@ const addLineToGradle = ( return lines.join('\n'); }; +const HORIZON_APP_ID_META_DATA_NAME = + 'com.meta.horizon.platform.ovr.OCULUS_APP_ID'; + +type AndroidManifestLike = { + manifest: { + application?: Record[]; + }; +}; + +type HorizonAppIdSyncResult = 'added' | 'updated' | 'removed' | 'unchanged'; + +export const normalizeGeneratedGroovyProjectBuildGradle = ( + gradle: string, +): string => + gradle.replace( + /maven\s*\{\s*url\s+(['"])https:\/\/www\.jitpack\.io\1\s*\}/g, + "maven { url = uri('https://www.jitpack.io') }", + ); + +export const normalizeGeneratedGroovyAppBuildGradle = ( + gradle: string, +): string => { + let modified = gradle; + const replacements: Array<[RegExp, string]> = [ + [ + /^(\s*)ndkVersion\s+rootProject\.ext\.ndkVersion\s*$/gm, + '$1ndkVersion = rootProject.ext.ndkVersion', + ], + [ + /^(\s*)buildToolsVersion\s+rootProject\.ext\.buildToolsVersion\s*$/gm, + '$1buildToolsVersion = rootProject.ext.buildToolsVersion', + ], + [ + /^(\s*)compileSdk\s+rootProject\.ext\.compileSdkVersion\s*$/gm, + '$1compileSdk = rootProject.ext.compileSdkVersion', + ], + [/^(\s*)namespace\s+(['"][^'"]+['"])\s*$/gm, '$1namespace = $2'], + [/^(\s*)applicationId\s+(['"][^'"]+['"])\s*$/gm, '$1applicationId = $2'], + [ + /^(\s*)minSdkVersion\s+rootProject\.ext\.minSdkVersion\s*$/gm, + '$1minSdk = rootProject.ext.minSdkVersion', + ], + [ + /^(\s*)targetSdkVersion\s+rootProject\.ext\.targetSdkVersion\s*$/gm, + '$1targetSdk = rootProject.ext.targetSdkVersion', + ], + [ + /^(\s*)signingConfig\s+signingConfigs\.debug\s*$/gm, + '$1signingConfig = signingConfigs.debug', + ], + [ + /^(\s*)shrinkResources\s+enableShrinkResources\.toBoolean\(\)\s*$/gm, + '$1shrinkResources = enableShrinkResources.toBoolean()', + ], + [ + /^(\s*)crunchPngs\s+enablePngCrunchInRelease\.toBoolean\(\)\s*$/gm, + '$1crunchPngs = enablePngCrunchInRelease.toBoolean()', + ], + [ + /^(\s*)useLegacyPackaging\s+enableLegacyPackaging\.toBoolean\(\)\s*$/gm, + '$1useLegacyPackaging = enableLegacyPackaging.toBoolean()', + ], + [ + /^(\s*)ignoreAssetsPattern\s+(['"][^'"]+['"])\s*$/gm, + '$1ignoreAssetsPattern = $2', + ], + ]; + + for (const [pattern, replacement] of replacements) { + modified = modified.replace(pattern, replacement); + } + + return modified; +}; + +export function syncHorizonAppIdMetaData( + manifest: AndroidManifestLike, + isHorizonEnabled?: boolean, + horizonAppId?: string, +): HorizonAppIdSyncResult { + const application = manifest.manifest.application?.[0]; + if (application?.['meta-data'] && !Array.isArray(application['meta-data'])) { + application['meta-data'] = [application['meta-data']]; + } + const existingMetaData = application?.['meta-data']; + + if (!isHorizonEnabled) { + if (!Array.isArray(existingMetaData)) return 'unchanged'; + + const nextMetaData = existingMetaData.filter( + (m) => m.$?.['android:name'] !== HORIZON_APP_ID_META_DATA_NAME, + ); + if (nextMetaData.length === existingMetaData.length || !application) { + return 'unchanged'; + } + + application['meta-data'] = nextMetaData; + return 'removed'; + } + + if (!horizonAppId) return 'unchanged'; + + if ( + !manifest.manifest.application || + manifest.manifest.application.length === 0 + ) { + manifest.manifest.application = [{$: {}}]; + } + + const horizonApplication = manifest.manifest.application[0]!; + if (!horizonApplication['meta-data']) { + horizonApplication['meta-data'] = []; + } + + const metaData = horizonApplication['meta-data']; + const horizonAppIdMeta = { + $: { + 'android:name': HORIZON_APP_ID_META_DATA_NAME, + 'android:value': horizonAppId, + }, + }; + + const existingIndex = metaData.findIndex( + (m: any) => m.$?.['android:name'] === HORIZON_APP_ID_META_DATA_NAME, + ); + + if (existingIndex !== -1) { + metaData[existingIndex] = horizonAppIdMeta; + return 'updated'; + } + + metaData.push(horizonAppIdMeta); + return 'added'; +} + export const modifyAppBuildGradle = ( gradle: string, language: 'groovy' | 'kotlin', isHorizonEnabled?: boolean, + isFireOsEnabled?: boolean, ): string => { - let modified = gradle; + 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 + })`, + ); + } + } - // Determine which flavor to use based on isHorizonEnabled - const flavor = isHorizonEnabled ? 'horizon' : 'play'; + let modified = + language === 'groovy' + ? normalizeGeneratedGroovyAppBuildGradle(gradle) + : gradle; - // Use openiap-google-horizon artifact when horizon is enabled - const artifactId = isHorizonEnabled + 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 + })`, + ); + return gradle; + } + + // Determine which flavor to use based on store flags. + const flavor = isFireOsEnabled + ? 'amazon' + : isHorizonEnabled + ? 'horizon' + : 'play'; + + const artifactId = isFireOsEnabled + ? 'openiap-google-amazon' + : isHorizonEnabled ? 'openiap-google-horizon' : 'openiap-google'; @@ -84,62 +259,57 @@ export const modifyAppBuildGradle = ( : ` implementation "${ga}:${v}"`; const openiapDep = impl( `io.github.hyochan.openiap:${artifactId}`, - OPENIAP_ANDROID_VERSION, + openIapAndroidVersion, ); - // Remove any existing openiap-google or openiap-google-horizon lines (any version, groovy/kotlin, implementation/api) + // 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)?:[^"']+["']\s*\)?\s*$/gm; - const hadExisting = openiapAnyLine.test(modified); + /^\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 = modified.replace(openiapAnyLine, '').replace(/\n{3,}/g, '\n\n'); + modified = withoutExistingOpeniap.replace(/\n{3,}/g, '\n\n'); } // Ensure the desired dependency line is present if ( !new RegExp( - String.raw`io\.github\.hyochan\.openiap:${artifactId}:${OPENIAP_ANDROID_VERSION}`, + String.raw`io\.github\.hyochan\.openiap:${artifactId}:${openIapAndroidVersion}`, ).test(modified) ) { // Insert just after the opening `dependencies {` line modified = addLineToGradle(modified, /dependencies\s*{/, openiapDep, 1); logOnce( hadExisting - ? `🛠️ expo-iap: Replaced OpenIAP dependency with ${OPENIAP_ANDROID_VERSION}` - : `🛠️ expo-iap: Added OpenIAP dependency (${OPENIAP_ANDROID_VERSION}) to build.gradle`, + ? `🛠️ expo-iap: Replaced OpenIAP dependency with ${openIapAndroidVersion}` + : `🛠️ expo-iap: Added OpenIAP dependency (${openIapAndroidVersion}) to build.gradle`, ); } - // Add flavor dimension and default config for OpenIAP if horizon is enabled - if (isHorizonEnabled) { - // Add missingDimensionStrategy to select horizon flavor - const defaultConfigRegex = /defaultConfig\s*{/; - if (defaultConfigRegex.test(modified)) { - const strategyLine = - language === 'kotlin' - ? ` missingDimensionStrategy("platform", "${flavor}")` - : ` missingDimensionStrategy "platform", "${flavor}"`; - - // Remove any existing platform strategies first to avoid duplicates - const strategyPattern = - /^\s*missingDimensionStrategy\s*\(?\s*["']platform["']\s*,\s*["'](play|horizon)["']\s*\)?\s*$/gm; - if (strategyPattern.test(modified)) { - modified = modified.replace(strategyPattern, ''); - logOnce('🧹 Removed existing missingDimensionStrategy for platform'); - } + // 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'); + } - // Add the new strategy - if (!/missingDimensionStrategy.*platform/.test(modified)) { - modified = addLineToGradle( - modified, - defaultConfigRegex, - strategyLine, - 1, - ); - logOnce( - `🛠️ expo-iap: Added missingDimensionStrategy for ${flavor} flavor`, - ); - } + 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); + logOnce( + `🛠️ expo-iap: Added missingDimensionStrategy for ${flavor} flavor`, + ); } } @@ -151,40 +321,64 @@ const withIapAndroid: ConfigPlugin< addDeps?: boolean; horizonAppId?: string; isHorizonEnabled?: boolean; + isFireOsEnabled?: boolean; } | void > = (config, props) => { const addDeps = props?.addDeps ?? true; - // Add dependencies if needed (only when not using local module) - if (addDeps) { - config = withAppBuildGradle(config, (config) => { - const language = (config.modResults as any).language || 'groovy'; - config.modResults.contents = modifyAppBuildGradle( + config = withProjectBuildGradle(config, (config) => { + const language = (config.modResults as any).language || 'groovy'; + if (language === 'groovy') { + config.modResults.contents = normalizeGeneratedGroovyProjectBuildGradle( config.modResults.contents, - language, - props?.isHorizonEnabled, ); - return config; - }); - } + } + return config; + }); + + 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; + + return config; + }); - // Set horizonEnabled property in gradle.properties so expo-iap module can pick it up + // 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; - // Remove any existing horizonEnabled entries config.modResults = config.modResults.filter( - (item) => item.type !== 'property' || item.key !== 'horizonEnabled', + (item) => + item.type !== 'property' || + !['horizonEnabled', 'fireOsEnabled'].includes(item.key), ); - // Add the horizonEnabled property 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; }); @@ -193,69 +387,58 @@ const withIapAndroid: ConfigPlugin< config = withAndroidManifest(config, (config) => { const manifest = config.modResults; - if (!manifest.manifest['uses-permission']) { - manifest.manifest['uses-permission'] = []; - } - - const permissions = manifest.manifest['uses-permission']; + const existingPermissions = manifest.manifest['uses-permission']; + const permissions = Array.isArray(existingPermissions) + ? existingPermissions + : existingPermissions + ? [existingPermissions] + : []; + manifest.manifest['uses-permission'] = permissions; const billingPerm = {$: {'android:name': 'com.android.vending.BILLING'}}; - const alreadyExists = permissions.some( - (p) => p.$['android:name'] === 'com.android.vending.BILLING', - ); - if (!alreadyExists) { - permissions.push(billingPerm); - logOnce('✅ Added com.android.vending.BILLING to AndroidManifest.xml'); - } else { - logOnce( - 'ℹ️ com.android.vending.BILLING already exists in AndroidManifest.xml', - ); - } - - // Add Meta Horizon App ID if provided - if (props?.horizonAppId) { - if ( - !manifest.manifest.application || - manifest.manifest.application.length === 0 - ) { - manifest.manifest.application = [ - {$: {'android:name': '.MainApplication'}}, - ]; - } - - const application = manifest.manifest.application![0]; - if (!application['meta-data']) { - application['meta-data'] = []; - } - - const metaData = application['meta-data']; - - // Use the correct meta-data name for Horizon Platform SDK - const horizonMetaDataName = 'com.meta.horizon.platform.ovr.OCULUS_APP_ID'; - const horizonAppIdMeta = { - $: { - 'android:name': horizonMetaDataName, - 'android:value': props.horizonAppId, - }, - }; - - const existingIndex = metaData.findIndex( - (m) => m.$['android:name'] === horizonMetaDataName, + if (props?.isFireOsEnabled) { + const nextPermissions = permissions.filter( + (p) => p.$['android:name'] !== 'com.android.vending.BILLING', ); - - if (existingIndex !== -1) { - metaData[existingIndex] = horizonAppIdMeta; + if (nextPermissions.length !== permissions.length) { + manifest.manifest['uses-permission'] = nextPermissions; logOnce( - `✅ Updated ${horizonMetaDataName} to ${props.horizonAppId} in AndroidManifest.xml`, + '🧹 Removed com.android.vending.BILLING from AndroidManifest.xml', ); + } + } else { + const alreadyExists = permissions.some( + (p) => p.$['android:name'] === 'com.android.vending.BILLING', + ); + if (!alreadyExists) { + permissions.push(billingPerm); + logOnce('✅ Added com.android.vending.BILLING to AndroidManifest.xml'); } else { - metaData.push(horizonAppIdMeta); logOnce( - `✅ Added ${horizonMetaDataName}: ${props.horizonAppId} to AndroidManifest.xml`, + 'ℹ️ com.android.vending.BILLING already exists in AndroidManifest.xml', ); } } + const horizonAppIdSync = syncHorizonAppIdMetaData( + manifest, + props?.isHorizonEnabled, + props?.horizonAppId, + ); + if (horizonAppIdSync === 'removed') { + logOnce( + `🧹 Removed ${HORIZON_APP_ID_META_DATA_NAME} from AndroidManifest.xml`, + ); + } else if (horizonAppIdSync === 'updated') { + logOnce( + `✅ Updated ${HORIZON_APP_ID_META_DATA_NAME} to ${props?.horizonAppId} in AndroidManifest.xml`, + ); + } else if (horizonAppIdSync === 'added') { + logOnce( + `✅ Added ${HORIZON_APP_ID_META_DATA_NAME}: ${props?.horizonAppId} to AndroidManifest.xml`, + ); + } + return config; }); @@ -538,7 +721,19 @@ export interface ExpoIapPluginOptions { /** Enable local development mode */ enableLocalDev?: boolean; /** - * Optional modules configuration + * Horizon OS app ID for Quest devices. + * @deprecated Use android.horizonAppId instead. + * @platform android + */ + horizonAppId?: string; + /** + * iOS Alternative Billing configuration. + * @deprecated Use ios.alternativeBilling instead. + * @platform ios + */ + iosAlternativeBilling?: IOSAlternativeBillingConfig; + /** + * Non-Amazon optional modules configuration. */ modules?: { /** @@ -575,6 +770,15 @@ export interface ExpoIapPluginOptions { */ horizonAppId?: string; }; + /** + * Amazon platform targets. Fire OS and Vega OS can both be enabled in the + * same config, but they still produce separate build artifacts. + */ + amazon?: AmazonPlatformOptions; + /** + * Vega-specific project generation options. + */ + vega?: VegaProjectOptions; } export interface ModuleSelectionResult { @@ -583,6 +787,32 @@ export interface ModuleSelectionResult { includeOnside: boolean; } +export type AmazonPlatformFlags = { + isFireOsEnabled: boolean; + isVegaEnabled: boolean; + isHorizonEnabled: boolean; + isOnsideEnabled: boolean; +}; + +export function resolveAmazonPlatformFlags( + options?: Pick | void, +): AmazonPlatformFlags { + const amazon = options?.amazon; + const isFireOsEnabled = amazon?.fireOS ?? false; + const isVegaEnabled = amazon?.vegaOS ?? false; + const isHorizonEnabled = isFireOsEnabled + ? false + : options?.modules?.horizon ?? false; + const isOnsideEnabled = options?.modules?.onside ?? false; + + return { + isFireOsEnabled, + isVegaEnabled, + isHorizonEnabled, + isOnsideEnabled, + }; +} + /** * Determines which modules to include based on configuration. * - ExpoIap: Always included (standard StoreKit 2 support) @@ -626,6 +856,9 @@ const withIap: ConfigPlugin = ( config, options, ) => { + const {isFireOsEnabled, isVegaEnabled, isHorizonEnabled, isOnsideEnabled} = + resolveAmazonPlatformFlags(options); + try { // Add iapkitApiKey to extra if provided if (options?.iapkitApiKey) { @@ -636,15 +869,13 @@ const withIap: ConfigPlugin = ( logOnce('🔑 [expo-iap] Added iapkitApiKey to config.extra'); } - // Read Horizon configuration from modules - const isHorizonEnabled = options?.modules?.horizon ?? false; - const isOnsideEnabled = options?.modules?.onside ?? false; - - const horizonAppId = options?.android?.horizonAppId; - const iosAlternativeBilling = options?.ios?.alternativeBilling; + const horizonAppId = + options?.android?.horizonAppId ?? options?.horizonAppId; + const iosAlternativeBilling = + options?.ios?.alternativeBilling ?? options?.iosAlternativeBilling; logOnce( - `🔍 [expo-iap] Config values: horizonAppId=${horizonAppId}, isHorizonEnabled=${isHorizonEnabled}, isOnsideEnabled=${isOnsideEnabled}`, + `🔍 [expo-iap] Config values: horizonAppId=${horizonAppId}, isHorizonEnabled=${isHorizonEnabled}, isFireOsEnabled=${isFireOsEnabled}, isVegaEnabled=${isVegaEnabled}, isOnsideEnabled=${isOnsideEnabled}`, ); const {includeExpoIap, includeOnside} = resolveModuleSelection( @@ -676,6 +907,7 @@ const withIap: ConfigPlugin = ( addDeps: !isLocalDev, horizonAppId, isHorizonEnabled, + isFireOsEnabled, }); // iOS: choose one path to avoid overlap @@ -706,7 +938,8 @@ const withIap: ConfigPlugin = ( localPath: resolved, iosAlternativeBilling, horizonAppId, - isHorizonEnabled, // Resolved from modules.horizon (line 467) + isHorizonEnabled, + isFireOsEnabled, }); } } else { @@ -722,6 +955,10 @@ const withIap: ConfigPlugin = ( syncAutolinking(autolinkState); + if (isVegaEnabled) { + result = withVega(result, options?.vega); + } + return result; } catch (error) { WarningAggregator.addWarningAndroid( diff --git a/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts b/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts index e59b2e9d..99e8159a 100644 --- a/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts +++ b/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts @@ -15,7 +15,13 @@ import { * Plugin to add local OpenIAP pod dependency for development * This is only for local development with openiap-apple library */ -type LocalPathOption = string | {ios?: string; android?: string}; +export type LocalPathOption = string | {ios?: string; android?: string}; + +export const getAndroidLocalPathInput = ( + raw?: LocalPathOption, +): string | undefined => { + return typeof raw === 'string' ? raw : raw?.android; +}; interface AndroidGradlePluginVersions { kotlin: string; @@ -93,6 +99,8 @@ const withLocalOpenIAP: ConfigPlugin< horizonAppId?: string; /** Resolved from modules.horizon by withIAP */ isHorizonEnabled?: boolean; + /** Resolved from amazon.fireOS by withIAP */ + isFireOsEnabled?: boolean; } | void > = (config, props) => { // Import and apply iOS alternative billing configuration if provided @@ -178,7 +186,7 @@ const withLocalOpenIAP: ConfigPlugin< config = withSettingsGradle(config, (config) => { const raw = props?.localPath; const projectRoot = (config.modRequest as any).projectRoot as string; - const androidInput = typeof raw === 'string' ? undefined : raw?.android; + const androidInput = getAndroidLocalPathInput(raw); const androidModulePath = resolveAndroidModulePath(androidInput) || resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) || @@ -294,7 +302,7 @@ const withLocalOpenIAP: ConfigPlugin< config = withAppBuildGradle(config, (config) => { const projectRoot = (config.modRequest as any).projectRoot as string; const raw = props?.localPath; - const androidInput = typeof raw === 'string' ? undefined : raw?.android; + const androidInput = getAndroidLocalPathInput(raw); const androidModulePath = resolveAndroidModulePath(androidInput) || resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) || @@ -306,15 +314,19 @@ const withLocalOpenIAP: ConfigPlugin< const gradle = config.modResults; const dependencyLine = ` implementation project(':openiap-google')`; - const flavor = props?.isHorizonEnabled ? 'horizon' : 'play'; + const flavor = props?.isFireOsEnabled + ? 'amazon' + : props?.isHorizonEnabled + ? 'horizon' + : 'play'; const strategyLine = ` missingDimensionStrategy "platform", "${flavor}"`; let contents = gradle.contents; - // Remove Maven deps (both openiap-google and openiap-google-horizon) + // 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)?:[^"']+["']\s*\)?\s*$/gm; + /^\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( @@ -325,7 +337,7 @@ const withLocalOpenIAP: ConfigPlugin< // Add missingDimensionStrategy (required for flavored module) // Remove any existing platform strategies first to avoid duplicates const strategyPattern = - /^\s*missingDimensionStrategy\s*\(?\s*["']platform["']\s*,\s*["'](play|horizon)["']\s*\)?\s*$/gm; + /^\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'); @@ -358,7 +370,7 @@ const withLocalOpenIAP: ConfigPlugin< return config; }); - // 3) Set horizonEnabled in gradle.properties + // 3) Set store flags in gradle.properties config = withDangerousMod(config, [ 'android', async (config) => { @@ -371,16 +383,21 @@ const withLocalOpenIAP: ConfigPlugin< if (fs.existsSync(gradlePropertiesPath)) { let contents = fs.readFileSync(gradlePropertiesPath, 'utf8'); const isHorizon = props?.isHorizonEnabled ?? false; + const isFireOS = props?.isFireOsEnabled ?? false; - // Update horizonEnabled property 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; diff --git a/libraries/expo-iap/plugin/src/withVega.ts b/libraries/expo-iap/plugin/src/withVega.ts new file mode 100644 index 00000000..cc0c31b5 --- /dev/null +++ b/libraries/expo-iap/plugin/src/withVega.ts @@ -0,0 +1,481 @@ +import type {ExpoConfig} from '@expo/config-types'; +import { + ConfigPlugin, + WarningAggregator, + withDangerousMod, +} from 'expo/config-plugins'; +import * as fs from 'fs'; +import * as path from 'path'; + +const GENERATED_MARKER = '# @generated by expo-iap'; +const GENERATED_JS_MARKER = '// @generated by expo-iap'; +const DEFAULT_ICON_FILE = 'icon.png'; +const DEFAULT_BUILD_TYPE = 'Release'; +const DEFAULT_RUNTIME_MODULE = + '/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0'; + +const logOnce = (() => { + const printed = new Set(); + return (msg: string) => { + if (!printed.has(msg)) { + console.log(msg); + printed.add(msg); + } + }; +})(); + +export type VegaProjectOptions = { + /** + * Vega package id. Defaults to android.package, then ios.bundleIdentifier. + */ + packageId?: string; + /** + * User-visible Vega app title. Defaults to Expo config name. + */ + title?: string; + /** + * Main interactive component id. Defaults to `${packageId}.main`. + */ + componentId?: string; + /** + * Kepler app name used by React Native for Vega tooling. + */ + appName?: string; + /** + * Icon path copied to assets/image for manifest.toml. Defaults to config.icon. + */ + icon?: string; + /** + * Whether to update package.json with Vega dependencies, scripts, and kepler metadata. + */ + syncPackageJson?: boolean; +}; + +export type VegaProjectSettings = { + packageId: string; + title: string; + version: string; + iconFileName: string; + componentId: string; + appName: string; + buildType: typeof DEFAULT_BUILD_TYPE; +}; + +const escapeTomlString = (value: string): string => JSON.stringify(value); + +const sanitizePackageSegment = (segment: string): string => { + const cleaned = segment.replace(/[^A-Za-z0-9_]/g, '_'); + if (/^[A-Za-z]/.test(cleaned)) return cleaned; + return `app_${cleaned || 'app'}`; +}; + +export const normalizeVegaPackageId = (value: string): string => { + const segments = value + .trim() + .replace(/[^A-Za-z0-9._]/g, '.') + .split('.') + .filter(Boolean) + .map(sanitizePackageSegment); + + if (segments.length >= 2) { + return segments.join('.'); + } + + return ['dev', 'openiap', ...segments].join('.'); +}; + +const sanitizeVegaAppName = (value: string): string => { + const words = value + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`); + return words.join('') || 'OpenIapVega'; +}; + +const resolveIconFileName = (icon?: string): string => { + if (!icon) return DEFAULT_ICON_FILE; + const parsed = path.parse(icon); + return `${parsed.name || 'icon'}${parsed.ext || '.png'}`; +}; + +export const resolveVegaProjectSettings = ( + config: ExpoConfig, + options?: VegaProjectOptions, +): VegaProjectSettings => { + const packageId = normalizeVegaPackageId( + options?.packageId ?? + config.android?.package ?? + config.ios?.bundleIdentifier ?? + `dev.openiap.${config.slug}`, + ); + const title = options?.title ?? config.name ?? config.slug; + + return { + packageId, + title, + version: config.version ?? '1.0.0', + iconFileName: resolveIconFileName(options?.icon ?? config.icon), + componentId: options?.componentId ?? `${packageId}.main`, + appName: options?.appName ?? sanitizeVegaAppName(title), + buildType: DEFAULT_BUILD_TYPE, + }; +}; + +export const createVegaManifest = ( + settings: VegaProjectSettings, +): string => `${GENERATED_MARKER} +schema-version = 1 + +[package] +id = ${escapeTomlString(settings.packageId)} +title = ${escapeTomlString(settings.title)} +version = ${escapeTomlString(settings.version)} +icon = ${escapeTomlString(`@image/${settings.iconFileName}`)} + +[components] +[[components.interactive]] +id = ${escapeTomlString(settings.componentId)} +runtime-module = ${escapeTomlString(DEFAULT_RUNTIME_MODULE)} +launch-type = "singleton" +categories = ["com.amazon.category.main"] + +[wants] +[[wants.service]] +id = "com.amazon.inputmethod.service" + +[[wants.service]] +id = "com.amazon.network.service" + +[[wants.service]] +id = "com.amazon.iap.core.service" + +[[wants.module]] +id = "/com.amazon.iap.core@IIAPCoreUI" + +[needs] +[[needs.module]] +id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" +`; + +export const createVegaEntryPoint = (): string => `${GENERATED_JS_MARKER} +import {AppRegistry} from 'react-native'; +import App from './App'; +import {name as appName} from './app.json'; + +AppRegistry.registerComponent(appName, () => App); +`; + +export const createVegaAppJson = ( + settings: VegaProjectSettings, +): Record => ({ + name: settings.appName, + displayName: settings.title, + expoIapGenerated: true, +}); + +type MutablePackageJson = { + name?: string; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + kepler?: Record; +}; + +const setIfMissing = ( + target: Record, + key: string, + value: string, +) => { + if (!target[key]) { + target[key] = value; + } +}; + +const setDependency = (pkg: MutablePackageJson, key: string, value: string) => { + delete pkg.devDependencies?.[key]; + delete pkg.optionalDependencies?.[key]; + if (!pkg.dependencies) { + pkg.dependencies = {}; + } + setIfMissing(pkg.dependencies, key, value); +}; + +const setDevDependency = ( + pkg: MutablePackageJson, + key: string, + value: string, +) => { + delete pkg.dependencies?.[key]; + delete pkg.optionalDependencies?.[key]; + if (!pkg.devDependencies) { + pkg.devDependencies = {}; + } + setIfMissing(pkg.devDependencies, key, value); +}; + +const setOptionalDependency = ( + pkg: MutablePackageJson, + key: string, + value: string, +) => { + delete pkg.dependencies?.[key]; + delete pkg.devDependencies?.[key]; + if (!pkg.optionalDependencies) { + pkg.optionalDependencies = {}; + } + setIfMissing(pkg.optionalDependencies, key, value); +}; + +const getVpkgBaseName = ( + pkg: MutablePackageJson, + settings: VegaProjectSettings, +): string => { + const rawName = + typeof pkg.name === 'string' && pkg.name.trim() + ? pkg.name.split('/').pop() + : undefined; + return (rawName ?? settings.appName) + .replace(/[^A-Za-z0-9_-]/g, '') + .toLowerCase(); +}; + +export const mergeVegaPackageJson = ( + pkg: T, + settings: VegaProjectSettings, +): T & MutablePackageJson => { + const next: T & MutablePackageJson = {...pkg}; + next.scripts = {...(pkg.scripts ?? {})}; + next.dependencies = {...(pkg.dependencies ?? {})}; + next.devDependencies = {...(pkg.devDependencies ?? {})}; + next.optionalDependencies = {...(pkg.optionalDependencies ?? {})}; + const vpkgBaseName = getVpkgBaseName(pkg, settings); + + setIfMissing( + next.scripts, + 'vega:prebuild', + 'EXPO_IAP_VEGA=1 expo prebuild --platform android --no-install', + ); + setIfMissing( + next.scripts, + 'build:vega:release', + 'EXPO_IAP_VEGA=1 expo prebuild --platform android --no-install && EXPO_IAP_VEGA=1 react-native build-vega --build-type Release', + ); + setIfMissing( + next.scripts, + 'build:vega:debug', + 'EXPO_IAP_VEGA=1 expo prebuild --platform android --no-install && EXPO_IAP_VEGA=1 react-native build-vega --build-type Debug', + ); + setIfMissing( + next.scripts, + 'run:vega:firetv', + `vega device install-app --packagePath build/armv7-debug/${vpkgBaseName}_armv7.vpkg && vega device launch-app --appName ${settings.componentId}`, + ); + + setDependency( + next, + '@amazon-devices/keplerscript-appstore-iap-lib', + '~2.12.13', + ); + + setDevDependency(next, '@amazon-devices/kepler-cli-platform', '~0.22.0'); + setDevDependency( + next, + '@amazon-devices/kepler-compatibility-metro-config', + '^0.0.6', + ); + setDevDependency( + next, + '@amazon-devices/kepler-module-resolver-preset', + '^0.1.15', + ); + setOptionalDependency(next, '@amazon-devices/react-native-kepler', '^2.0.0'); + setIfMissing(next.devDependencies, '@react-native-community/cli', '11.3.2'); + setIfMissing(next.devDependencies, '@react-native/metro-config', '^0.72.6'); + setIfMissing(next.devDependencies, 'babel-plugin-module-resolver', '^5.0.2'); + setIfMissing( + next.devDependencies, + 'metro-react-native-babel-preset', + '~0.76.9', + ); + + next.kepler = { + ...(pkg.kepler ?? {}), + projectType: 'application', + appName: settings.appName, + targets: ['tv'], + os: ['vega'], + api: 0.1, + }; + + return next; +}; + +const syncGeneratedFile = (filePath: string, content: string): boolean => { + if (fs.existsSync(filePath)) { + const existing = fs.readFileSync(filePath, 'utf8'); + if (!existing.includes(GENERATED_MARKER)) { + WarningAggregator.addWarningAndroid( + 'expo-iap', + `Vega file already exists and is not expo-iap generated: ${filePath}`, + ); + return false; + } + if (existing === content) return false; + } + + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, content, 'utf8'); + return true; +}; + +const syncGeneratedJavaScriptFile = ( + filePath: string, + content: string, +): boolean => { + if (fs.existsSync(filePath)) { + const existing = fs.readFileSync(filePath, 'utf8'); + if (!existing.includes(GENERATED_JS_MARKER)) { + return false; + } + if (existing === content) return false; + } + + fs.writeFileSync(filePath, content, 'utf8'); + return true; +}; + +const syncAppJson = ( + projectRoot: string, + settings: VegaProjectSettings, +): boolean => { + const appJsonPath = path.join(projectRoot, 'app.json'); + const content = `${JSON.stringify(createVegaAppJson(settings), null, 2)}\n`; + + if (fs.existsSync(appJsonPath)) { + const existing = fs.readFileSync(appJsonPath, 'utf8'); + try { + const parsed = JSON.parse(existing); + if (parsed?.expoIapGenerated !== true) { + return false; + } + } catch { + return false; + } + if (existing === content) return false; + } + + fs.writeFileSync(appJsonPath, content, 'utf8'); + return true; +}; + +const syncPackageJson = ( + projectRoot: string, + settings: VegaProjectSettings, +): boolean => { + const packageJsonPath = path.join(projectRoot, 'package.json'); + if (!fs.existsSync(packageJsonPath)) return false; + + const original = fs.readFileSync(packageJsonPath, 'utf8'); + const parsed = JSON.parse(original); + const next = mergeVegaPackageJson(parsed, settings); + const serialized = `${JSON.stringify(next, null, 2)}\n`; + if (serialized === original) return false; + + fs.writeFileSync(packageJsonPath, serialized, 'utf8'); + return true; +}; + +const syncIconAsset = ( + projectRoot: string, + config: ExpoConfig, + options?: VegaProjectOptions, +): boolean => { + const rawIcon = options?.icon ?? config.icon; + if (!rawIcon) { + WarningAggregator.addWarningAndroid( + 'expo-iap', + 'Vega manifest requires an icon. Set expo.icon or vega.icon.', + ); + return false; + } + + const sourcePath = path.resolve(projectRoot, rawIcon); + if (!fs.existsSync(sourcePath)) { + WarningAggregator.addWarningAndroid( + 'expo-iap', + `Vega icon not found: ${sourcePath}`, + ); + return false; + } + + const outputPath = path.join( + projectRoot, + 'assets', + 'image', + resolveIconFileName(rawIcon), + ); + fs.mkdirSync(path.dirname(outputPath), {recursive: true}); + + if ( + fs.existsSync(outputPath) && + fs.readFileSync(outputPath).equals(fs.readFileSync(sourcePath)) + ) { + return false; + } + + fs.copyFileSync(sourcePath, outputPath); + return true; +}; + +const withVega: ConfigPlugin = (config, options) => { + return withDangerousMod(config, [ + 'android', + async (modConfig) => { + const projectRoot = modConfig.modRequest.projectRoot; + const settings = resolveVegaProjectSettings( + modConfig as ExpoConfig, + options ?? undefined, + ); + + const changedManifest = syncGeneratedFile( + path.join(projectRoot, 'manifest.toml'), + createVegaManifest(settings), + ); + const changedPackage = + options?.syncPackageJson === false + ? false + : syncPackageJson(projectRoot, settings); + const changedIcon = syncIconAsset( + projectRoot, + modConfig as ExpoConfig, + options ?? undefined, + ); + const changedEntry = syncGeneratedJavaScriptFile( + path.join(projectRoot, 'index.js'), + createVegaEntryPoint(), + ); + const changedAppJson = syncAppJson(projectRoot, settings); + + if (changedManifest) { + logOnce('🛠️ expo-iap: Wrote Vega manifest.toml'); + } + if (changedPackage) { + logOnce('🛠️ expo-iap: Synced Vega package.json metadata'); + } + if (changedIcon) { + logOnce('🛠️ expo-iap: Copied Vega icon asset'); + } + if (changedEntry) { + logOnce('🛠️ expo-iap: Wrote Vega index.js entry point'); + } + if (changedAppJson) { + logOnce('🛠️ expo-iap: Wrote Vega app.json metadata'); + } + + return modConfig; + }, + ]); +}; + +export default withVega; diff --git a/libraries/expo-iap/src/ExpoIapModule.ts b/libraries/expo-iap/src/ExpoIapModule.ts index 35428b63..468e60bd 100644 --- a/libraries/expo-iap/src/ExpoIapModule.ts +++ b/libraries/expo-iap/src/ExpoIapModule.ts @@ -1,59 +1,75 @@ import {requireNativeModule, UnavailabilityError} from 'expo-modules-core'; import {installedFromOnside} from './onside'; +import {getVegaIapModule, isVegaOS} from './vega'; -type NativeIapModuleName = 'ExpoIapOnside' | 'ExpoIap'; +type NativeIapModuleName = 'ExpoIapVega' | 'ExpoIapOnside' | 'ExpoIap'; const ONSIDE_MARKETPLACE_ID = 'com.onside.marketplace-app'; let cached: {module: any; name: NativeIapModuleName} | null = null; let expoIapFallback: any | null | undefined; let onsideModuleUnavailable = false; -function isOnsideInstallation(): boolean { - if (installedFromOnside === true) { - return true; - } +function getResolved(): {module: any; name: NativeIapModuleName} { + function shouldUseOnsideModule(): boolean { + if (installedFromOnside === true) { + return true; + } - if (typeof installedFromOnside !== 'string') { - return false; - } + if (typeof installedFromOnside !== 'string') { + return false; + } - const normalized = installedFromOnside.trim().toLowerCase(); - return normalized === 'true' || normalized === ONSIDE_MARKETPLACE_ID; -} + const normalized = installedFromOnside.trim().toLowerCase(); + return normalized === 'true' || normalized === ONSIDE_MARKETPLACE_ID; + } -function shouldUseOnsideModule(): boolean { - return isOnsideInstallation() && !onsideModuleUnavailable; -} + function getExpectedModuleName(): NativeIapModuleName { + if (isVegaOS()) { + return 'ExpoIapVega'; + } -function getResolved(): {module: any; name: NativeIapModuleName} { - const expectedName: NativeIapModuleName = shouldUseOnsideModule() - ? 'ExpoIapOnside' - : 'ExpoIap'; - if (!cached || cached.name !== expectedName) { - cached = resolveNativeModule(); + return shouldUseOnsideModule() && !onsideModuleUnavailable + ? 'ExpoIapOnside' + : 'ExpoIap'; } - return cached; -} -function resolveNativeModule(): { - module: any; - name: NativeIapModuleName; -} { - if (isOnsideInstallation()) { - try { - return { - module: requireNativeModule('ExpoIapOnside'), - name: 'ExpoIapOnside', - }; - } catch (error) { - if (!isMissingModuleError(error, 'ExpoIapOnside')) { - throw error; + function resolveNativeModule(): { + module: any; + name: NativeIapModuleName; + } { + if (isVegaOS()) { + const vegaModule = getVegaIapModule(); + if (!vegaModule) { + throw new UnavailabilityError( + 'expo-iap', + 'Amazon Vega IAP module is unavailable. Install @amazon-devices/keplerscript-appstore-iap-lib in the Vega app target and build with the React Native for Vega kepler platform.', + ); } - onsideModuleUnavailable = true; + return {module: vegaModule, name: 'ExpoIapVega'}; } + + if (shouldUseOnsideModule()) { + try { + return { + module: requireNativeModule('ExpoIapOnside'), + name: 'ExpoIapOnside', + }; + } catch (error) { + if (!isMissingModuleError(error, 'ExpoIapOnside')) { + throw error; + } + onsideModuleUnavailable = true; + } + } + + return {module: requireNativeModule('ExpoIap'), name: 'ExpoIap'}; } - return {module: requireNativeModule('ExpoIap'), name: 'ExpoIap'}; + const expectedName = getExpectedModuleName(); + if (!cached || cached.name !== expectedName) { + cached = resolveNativeModule(); + } + return cached; } function isMissingModuleError(error: unknown, moduleName: string): boolean { @@ -68,6 +84,24 @@ function isMissingModuleError(error: unknown, moduleName: string): boolean { return false; } +function getExpoIapFallbackModule(): any | null { + if (expoIapFallback !== undefined) { + return expoIapFallback; + } + + try { + expoIapFallback = requireNativeModule('ExpoIap'); + } catch (error) { + if (isMissingModuleError(error, 'ExpoIap')) { + expoIapFallback = null; + } else { + throw error; + } + } + + return expoIapFallback; +} + export const NATIVE_ERROR_CODES: Record = new Proxy( {} as Record, { @@ -88,24 +122,6 @@ export function getNativeModule() { return getResolved().module; } -function getExpoIapFallbackModule(): any | null { - if (expoIapFallback !== undefined) { - return expoIapFallback; - } - - try { - expoIapFallback = requireNativeModule('ExpoIap'); - } catch (error) { - if (isMissingModuleError(error, 'ExpoIap')) { - expoIapFallback = null; - } else { - throw error; - } - } - - return expoIapFallback; -} - export default new Proxy({} as any, { get(target, prop) { if (typeof prop === 'symbol') return Reflect.get(target, prop); @@ -113,6 +129,9 @@ export default new Proxy({} as any, { if (prop === 'USING_ONSIDE_SDK') { return resolved.name === 'ExpoIapOnside'; } + if (prop === 'USING_VEGA_SDK') { + return resolved.name === 'ExpoIapVega'; + } const value = resolved.module[prop]; if (value !== undefined || resolved.name !== 'ExpoIapOnside') { diff --git a/libraries/expo-iap/src/__tests__/native-log-redaction.test.js b/libraries/expo-iap/src/__tests__/native-log-redaction.test.js new file mode 100644 index 00000000..f0a9b844 --- /dev/null +++ b/libraries/expo-iap/src/__tests__/native-log-redaction.test.js @@ -0,0 +1,62 @@ +/* eslint-env jest, node */ + +const {readFileSync} = require('fs'); +const {resolve} = require('path'); + +const rootDir = resolve(__dirname, '../..'); + +function readRepoFile(path) { + return readFileSync(resolve(rootDir, path), 'utf8'); +} + +describe('native log redaction', () => { + it('keeps Expo native log sanitizers covering verification secrets', () => { + const androidLog = readRepoFile( + 'android/src/main/java/expo/modules/iap/ExpoIapLog.kt', + ); + const iosLog = readRepoFile('ios/ExpoIapLog.swift'); + const sensitiveFragments = [ + 'token', + 'apikey', + 'secret', + 'jws', + 'receiptid', + 'userid', + ]; + + for (const fragment of sensitiveFragments) { + expect(androidLog).toContain(`"${fragment}"`); + expect(iosLog).toContain(`"${fragment}"`); + } + expect(androidLog).toContain('isSensitiveKey'); + expect(iosLog).toContain('isSensitiveKey'); + expect(androidLog).toContain('filter { it.isLetterOrDigit() }'); + expect(iosLog).toContain('.filter { $0.isLetter || $0.isNumber }'); + }); + + it('does not log raw verifyPurchaseWithProvider params on Expo native bridges', () => { + const androidModule = readRepoFile( + 'android/src/main/java/expo/modules/iap/ExpoIapModule.kt', + ); + const iosModule = readRepoFile('ios/ExpoIapModule.swift'); + + expect(androidModule).not.toContain( + 'ExpoIapLog.payload("verifyPurchaseWithProvider", params)', + ); + expect(iosModule).not.toContain( + 'ExpoIapLog.payload("verifyPurchaseWithProvider", payload: params)', + ); + expect(androidModule).toContain('"hasIapkit" to (params["iapkit"] != null)'); + expect(iosModule).toContain('"hasIapkit": params["iapkit"] != nil'); + }); + + it('does not log raw IAPKit request bodies in the Apple core package', () => { + const appleModule = readFileSync( + resolve(rootDir, '../../packages/apple/Sources/OpenIapModule.swift'), + 'utf8', + ); + + expect(appleModule).not.toContain('IAPKit request body:'); + expect(appleModule).toContain('IAPKit request body bytes='); + }); +}); diff --git a/libraries/expo-iap/src/__tests__/useIAP.test.tsx b/libraries/expo-iap/src/__tests__/useIAP.test.tsx index c888f63c..8e59ba9c 100644 --- a/libraries/expo-iap/src/__tests__/useIAP.test.tsx +++ b/libraries/expo-iap/src/__tests__/useIAP.test.tsx @@ -14,6 +14,7 @@ jest.mock('react-native', () => ({ import * as React from 'react'; import * as ReactTestRenderer from 'react-test-renderer'; import ExpoIapModule from '../ExpoIapModule'; +import {ErrorCode} from '../types'; import {useIAP, UseIAPOptions} from '../useIAP'; /* eslint-enable import/first */ @@ -53,6 +54,7 @@ function TestComponent({ // Helper to wait for async operations const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); +const purchaseErrorEvent = 'purchase-error'; describe('useIAP hook', () => { beforeEach(() => { @@ -427,4 +429,63 @@ describe('useIAP hook', () => { expect(ExpoIapModule.initConnection).toHaveBeenCalled(); }); }); + + describe('purchase error listener', () => { + const renderWithCapturedListeners = async () => { + const listeners: Record void> = {}; + (ExpoIapModule.addListener as jest.Mock) = jest.fn( + (eventName: string, listener: (payload: any) => void) => { + listeners[eventName] = listener; + return {remove: jest.fn()}; + }, + ); + + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create( {}} />); + await flushPromises(); + }); + + await ReactTestRenderer.act(async () => { + await flushPromises(); + }); + + return listeners; + }; + + it('does not warn for already-owned purchase errors', async () => { + const listeners = await renderWithCapturedListeners(); + consoleWarnSpy.mockClear(); + + listeners[purchaseErrorEvent]({ + code: ErrorCode.AlreadyOwned, + message: 'Already owned', + }); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn for service-timeout purchase errors', async () => { + const listeners = await renderWithCapturedListeners(); + consoleWarnSpy.mockClear(); + + listeners[purchaseErrorEvent]({ + code: ErrorCode.ServiceTimeout, + message: 'Service timeout', + }); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('warns for non-recoverable purchase errors', async () => { + const listeners = await renderWithCapturedListeners(); + consoleWarnSpy.mockClear(); + + listeners[purchaseErrorEvent]({ + code: ErrorCode.DeveloperError, + message: 'Developer error', + }); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts new file mode 100644 index 00000000..99bcbac7 --- /dev/null +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -0,0 +1,1427 @@ +import { + createExpoIapVegaModule, + type VegaPurchasingService, +} from '../vega-adapter'; +import {ErrorCode} from '../types'; + +const createService = (): jest.Mocked => + ({ + getUserData: jest.fn(async () => ({ + responseCode: 1, + userData: { + countryCode: 'US', + marketplace: 'US', + userId: 'amazon-user', + }, + })), + getProductData: jest.fn(async () => ({ + responseCode: 1, + productData: { + coins_100: { + sku: 'coins_100', + title: '100 Coins', + description: 'Coin pack', + productType: 1, + price: { + priceCurrencyCode: 'USD', + priceStr: '$0.99', + valueInMicros: 990000, + }, + }, + premium_monthly: { + sku: 'premium_monthly', + title: 'Premium Monthly', + description: 'Monthly plan', + productType: 3, + subscriptionPeriod: 'P1M', + price: { + priceCurrencyCode: 'USD', + priceStr: '$4.99', + valueInMicros: 4990000, + }, + }, + }, + })), + purchase: jest.fn(async () => ({ + responseCode: 0, + receipt: { + receiptId: 'receipt-1', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-05-11T00:00:00.000Z'), + }, + })), + getPurchaseUpdates: jest.fn(async () => ({ + responseCode: 1, + receiptList: [ + { + receiptId: 'sub-receipt', + sku: 'premium_monthly', + productType: 3, + purchaseDate: new Date('2026-05-10T00:00:00.000Z'), + }, + ], + })), + notifyFulfillment: jest.fn(async () => ({ + responseCode: 1, + })), + }) as unknown as jest.Mocked; + +describe('Amazon Vega Expo adapter', () => { + it('initializes without fetching Amazon user data', async () => { + const service = createService(); + const module = createExpoIapVegaModule(service); + + await expect(module.initConnection()).resolves.toBe(true); + + expect(service.getUserData).not.toHaveBeenCalled(); + }); + + it('maps Vega product data to OpenIAP Android products', async () => { + const service = createService(); + const module = createExpoIapVegaModule(service); + + const products = await module.fetchProducts('all', [ + 'coins_100', + 'premium_monthly', + ]); + + expect(service.getProductData).toHaveBeenCalledWith({ + skus: ['coins_100', 'premium_monthly'], + }); + expect(products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'coins_100', + type: 'in-app', + platform: 'android', + }), + expect.objectContaining({ + id: 'premium_monthly', + type: 'subs', + platform: 'android', + subscriptionOfferDetailsAndroid: expect.any(Array), + }), + ]), + ); + }); + + it('accepts Amazon Vega string success response codes', async () => { + const service = createService(); + service.getProductData.mockResolvedValueOnce({ + responseCode: 'SUCCESSFUL', + productData: new Map([ + [ + 'coins_100', + { + sku: 'coins_100', + title: '100 Coins', + description: 'Coin pack', + productType: 1, + price: { + priceCurrencyCode: 'USD', + priceStr: '$0.99', + valueInMicros: '990000', + }, + }, + ], + ]), + }); + const module = createExpoIapVegaModule(service); + + await expect(module.fetchProducts('all', ['coins_100'])).resolves.toEqual([ + expect.objectContaining({ + id: 'coins_100', + price: 0.99, + }), + ]); + }); + + it('maps App Tester catalog-shaped product data', async () => { + const service = createService(); + service.getProductData.mockResolvedValueOnce({ + responseCode: 1, + productData: { + 'dev.hyo.martie.10bulbs': { + itemType: 'CONSUMABLE', + price: 0.99, + title: '10 Bulbs', + description: 'A small pack of bulbs', + }, + 'dev.hyo.martie.premium': { + itemType: 'SUBSCRIPTION', + price: 4.99, + term: 'Monthly', + title: 'Premium Monthly', + description: 'Monthly premium access', + }, + }, + }); + const module = createExpoIapVegaModule(service); + + await expect( + module.fetchProducts('all', [ + 'dev.hyo.martie.10bulbs', + 'dev.hyo.martie.premium', + ]), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'dev.hyo.martie.10bulbs', + type: 'in-app', + price: 0.99, + }), + expect.objectContaining({ + id: 'dev.hyo.martie.premium', + type: 'subs', + price: 4.99, + }), + ]), + ); + }); + + it('emits purchase updates and acknowledges receipts', async () => { + const service = createService(); + const module = createExpoIapVegaModule(service); + const listener = jest.fn(); + + module.addListener('purchase-updated', listener); + const result = await module.requestPurchase({ + skuArr: ['coins_100'], + type: 'in-app', + }); + + expect(result).toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'receipt-1', + store: 'amazon', + }), + ]); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'receipt-1', + }), + ); + + await module.acknowledgePurchaseAndroid('receipt-1'); + + expect(service.notifyFulfillment).toHaveBeenCalledWith({ + fulfillmentResult: 1, + receiptId: 'receipt-1', + }); + }); + + it('retries transient Amazon Vega fulfillment failures', async () => { + jest.useFakeTimers(); + const service = createService(); + service.notifyFulfillment + .mockResolvedValueOnce({responseCode: 'FAILED'}) + .mockResolvedValueOnce({responseCode: 1}); + const module = createExpoIapVegaModule(service); + + try { + const result = module.acknowledgePurchaseAndroid('receipt-1'); + await Promise.resolve(); + jest.advanceTimersByTime(1_000); + + await expect(result).resolves.toBeUndefined(); + expect(service.notifyFulfillment).toHaveBeenCalledTimes(2); + expect(service.notifyFulfillment).toHaveBeenNthCalledWith(2, { + fulfillmentResult: 1, + receiptId: 'receipt-1', + }); + } finally { + jest.useRealTimers(); + } + }); + + it('recovers fulfillable receipts after Amazon Vega purchase failures', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 'FAILED', + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'recovered-receipt', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + const listener = jest.fn(); + const errorListener = jest.fn(); + module.addListener('purchase-updated', listener); + module.addListener('purchase-error', errorListener); + + await expect( + module.requestPurchase({ + skuArr: ['coins_100'], + type: 'in-app', + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + store: 'amazon', + }), + ]); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + }), + ); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(errorListener).not.toHaveBeenCalled(); + }); + + it('recovers fulfillable receipts after parser-only purchase errors', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-10T00:00:00.000Z')); + const service = createService(); + try { + service.purchase.mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'recovered-receipt', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-06-10T00:00:01.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + const listener = jest.fn(); + const errorListener = jest.fn(); + module.addListener('purchase-updated', listener); + module.addListener('purchase-error', errorListener); + + await expect( + module.requestPurchase({ + skuArr: ['coins_100'], + type: 'in-app', + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + store: 'amazon', + }), + ]); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + }), + ); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(errorListener).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('does not recover old receipts after parser-only purchase errors', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-10T00:00:00.000Z')); + const service = createService(); + const parserError = new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ); + try { + service.purchase.mockRejectedValueOnce(parserError); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'old-receipt', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-06-09T23:00:00.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + const listener = jest.fn(); + const errorListener = jest.fn(); + module.addListener('purchase-updated', listener); + module.addListener('purchase-error', errorListener); + + await expect( + module.requestPurchase({ + skuArr: ['coins_100'], + type: 'in-app', + }), + ).rejects.toBe(parserError); + expect(listener).not.toHaveBeenCalled(); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.PurchaseError, + }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('does not fulfill recovered purchases before the app finishes them', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 'FAILED', + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'recovered-receipt', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + + await expect( + module.requestPurchase({ + skuArr: ['coins_100'], + type: 'in-app', + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + }), + ]); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + }); + + it('emits other recovered receipts while preserving the original purchase failure', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 'FAILED', + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'previous-sub-receipt', + sku: 'premium_monthly', + productType: 3, + purchaseDate: new Date('2026-06-09T00:00:00.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + const listener = jest.fn(); + module.addListener('purchase-updated', listener); + + await expect( + module.requestPurchase({ + skuArr: ['coins_100'], + type: 'in-app', + }), + ).rejects.toMatchObject({ + code: ErrorCode.UserCancelled, + }); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(service.purchase).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'premium_monthly', + purchaseToken: 'previous-sub-receipt', + }), + ); + }); + + it('treats subscription base receipts as the requested subscription purchase', async () => { + const service = createService(); + service.getProductData.mockResolvedValueOnce({ + responseCode: 1, + productData: new Map([ + [ + 'premium_monthly', + { + sku: 'premium_monthly', + title: 'Premium Monthly', + description: 'Monthly plan', + productType: 3, + subscriptionBase: 'premium_monthly.base', + price: { + priceCurrencyCode: 'USD', + priceStr: '$4.99', + valueInMicros: 4990000, + }, + }, + ], + ]), + }); + service.purchase.mockResolvedValueOnce({ + responseCode: 4, + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'base-receipt', + sku: 'premium_monthly.base', + productType: 3, + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + + await module.fetchProducts('subs', ['premium_monthly']); + + await expect( + module.requestPurchase({ + skuArr: ['premium_monthly'], + type: 'subs', + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + purchaseToken: 'base-receipt', + }), + ]); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(service.purchase).toHaveBeenCalledTimes(1); + }); + + it('normalizes subscription base receipts in active subscription queries', async () => { + const service = createService(); + service.getProductData.mockResolvedValueOnce({ + responseCode: 1, + productData: new Map([ + [ + 'premium_monthly', + { + sku: 'premium_monthly', + title: 'Premium Monthly', + description: 'Monthly plan', + productType: 3, + subscriptionBase: 'premium_monthly.base', + price: { + priceCurrencyCode: 'USD', + priceStr: '$4.99', + valueInMicros: 4990000, + }, + }, + ], + ]), + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'base-receipt', + sku: 'premium_monthly.base', + productType: 3, + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + + await module.fetchProducts('subs', ['premium_monthly']); + + await expect( + module.getActiveSubscriptions(['premium_monthly']), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + basePlanIdAndroid: 'premium_monthly', + currentPlanId: 'premium_monthly', + purchaseToken: 'base-receipt', + }), + ]); + }); + + it('keeps original purchase failure when recovery parsing fails', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 'FAILED', + receipt: null, + }); + service.getPurchaseUpdates.mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + const module = createExpoIapVegaModule(service); + const errorListener = jest.fn(); + module.addListener('purchase-error', errorListener); + + await expect( + module.requestPurchase({ + skuArr: ['coins_100'], + type: 'in-app', + }), + ).rejects.toMatchObject({ + code: ErrorCode.UserCancelled, + }); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.UserCancelled, + }), + ); + }); + + it('maps Amazon invalid SKU purchase failures to OpenIAP errors', async () => { + const service = createService(); + service.purchase.mockResolvedValue({ + responseCode: 2, + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [], + }); + const module = createExpoIapVegaModule(service); + const errorListener = jest.fn(); + module.addListener('purchase-error', errorListener); + + await expect( + module.requestPurchase({ + skuArr: ['missing_sku'], + type: 'in-app', + }), + ).rejects.toMatchObject({ + code: ErrorCode.SkuNotFound, + productId: 'missing_sku', + }); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.SkuNotFound, + productId: 'missing_sku', + responseCode: 2, + }), + ); + }); + + it('returns active subscriptions from purchase updates', async () => { + const service = createService(); + const module = createExpoIapVegaModule(service); + + const subscriptions = await module.getActiveSubscriptions([ + 'premium_monthly', + ]); + + expect(service.getPurchaseUpdates).toHaveBeenCalledWith({reset: true}); + expect(subscriptions).toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isActive: true, + basePlanIdAndroid: 'premium_monthly', + currentPlanId: 'premium_monthly', + purchaseToken: 'sub-receipt', + }), + ]); + }); + + it('uses cached product types when purchase updates omit productType', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: [ + { + receiptId: 'sub-receipt', + sku: 'premium_monthly', + purchaseDate: new Date('2026-05-10T00:00:00.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + + await module.fetchProducts('subs', ['premium_monthly']); + + await expect( + module.getActiveSubscriptions(['premium_monthly']), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isActive: true, + basePlanIdAndroid: 'premium_monthly', + currentPlanId: 'premium_monthly', + purchaseToken: 'sub-receipt', + }), + ]); + }); + + it('hydrates product types when purchase updates omit productType before fetchProducts', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: [ + { + receiptId: 'sub-receipt', + sku: 'premium_monthly', + purchaseDate: new Date('2026-05-10T00:00:00.000Z'), + }, + ], + }); + const module = createExpoIapVegaModule(service); + + await expect( + module.getActiveSubscriptions(['premium_monthly']), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isActive: true, + basePlanIdAndroid: 'premium_monthly', + currentPlanId: 'premium_monthly', + purchaseToken: 'sub-receipt', + }), + ]); + expect(service.getProductData).toHaveBeenCalledWith({ + skus: ['premium_monthly'], + }); + }); + + it('uses subscription request context when purchase receipts omit productType', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 0, + receipt: { + receiptId: 'sub-purchase', + sku: 'premium_monthly', + }, + }); + const module = createExpoIapVegaModule(service); + + await expect( + module.requestPurchase({ + skuArr: ['premium_monthly'], + type: 'subs', + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isAutoRenewing: true, + autoRenewingAndroid: true, + }), + ]); + }); + + it('loads all paginated Amazon purchase updates', async () => { + const service = createService(); + service.getPurchaseUpdates + .mockResolvedValueOnce({ + responseCode: 1, + hasMore: true, + receiptList: [ + { + receiptId: 'receipt-page-1', + sku: 'coins_100', + productType: 1, + }, + ], + }) + .mockResolvedValueOnce({ + responseCode: 1, + hasMore: false, + receiptList: [ + { + receiptId: 'receipt-page-2', + sku: 'premium_monthly', + productType: 3, + }, + ], + }); + const module = createExpoIapVegaModule(service); + + const purchases = await module.getAvailableItems(); + + expect(service.getPurchaseUpdates).toHaveBeenNthCalledWith(1, { + reset: true, + }); + expect(service.getPurchaseUpdates).toHaveBeenNthCalledWith(2, { + reset: false, + }); + expect(purchases.map((purchase) => purchase.id)).toEqual([ + 'receipt-page-1', + 'receipt-page-2', + ]); + }); + + it('treats Amazon parser-only purchase update errors as no updates', async () => { + const service = createService(); + service.getPurchaseUpdates.mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + const module = createExpoIapVegaModule(service); + + await expect(module.getAvailableItems()).resolves.toEqual([]); + }); + + it('retries failed Amazon purchase update responses', async () => { + jest.useFakeTimers(); + const service = createService(); + service.getPurchaseUpdates + .mockResolvedValueOnce({ + responseCode: 3, + receiptList: [], + }) + .mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'recovered-receipt', + sku: 'coins_100', + productType: 1, + }, + ], + }); + const module = createExpoIapVegaModule(service); + + try { + const result = module.getAvailableItems(); + await Promise.resolve(); + jest.advanceTimersByTime(1_000); + + await expect(result).resolves.toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + }), + ]); + expect(service.getPurchaseUpdates).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('ignores parser-only product type hydration errors for purchase updates', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'base-receipt', + sku: 'premium_monthly.base', + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + service.getProductData.mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + const module = createExpoIapVegaModule(service); + + await expect( + module.getActiveSubscriptions(['premium_monthly']), + ).resolves.toEqual([]); + }); + + it('limits paginated Amazon purchase updates', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + hasMore: true, + receiptList: [], + }); + const module = createExpoIapVegaModule(service); + + await expect(module.getAvailableItems()).rejects.toMatchObject({ + code: ErrorCode.ServiceError, + }); + expect(service.getPurchaseUpdates).toHaveBeenCalledTimes(100); + }); + + it('chunks Vega product data requests', async () => { + const service = createService(); + const skus = Array.from({length: 101}, (_, index) => `sku_${index}`); + service.getProductData.mockImplementation(async ({skus: batch}) => ({ + responseCode: 1, + productData: Object.fromEntries( + batch.map((sku) => [ + sku, + { + sku, + title: sku, + description: 'Product', + productType: 1, + price: { + priceCurrencyCode: 'USD', + priceStr: '$0.99', + valueInMicros: 990000, + }, + }, + ]), + ), + })); + const module = createExpoIapVegaModule(service); + + const products = await module.fetchProducts('all', skus); + + expect(products).toHaveLength(101); + expect(service.getProductData).toHaveBeenCalledTimes(2); + expect(service.getProductData.mock.calls[0]?.[0].skus).toHaveLength(100); + expect(service.getProductData.mock.calls[1]?.[0].skus).toHaveLength(1); + }); + + it('chunks product type hydration for purchase updates', async () => { + const service = createService(); + const skus = Array.from({length: 101}, (_, index) => `sub_${index}`); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: skus.map((sku) => ({ + receiptId: `receipt_${sku}`, + sku, + })), + }); + service.getProductData.mockImplementation(async ({skus: batch}) => ({ + responseCode: 1, + productData: Object.fromEntries( + batch.map((sku) => [ + sku, + { + sku, + title: sku, + description: 'Subscription', + productType: 3, + subscriptionPeriod: 'P1M', + price: { + priceCurrencyCode: 'USD', + priceStr: '$4.99', + valueInMicros: 4990000, + }, + }, + ]), + ), + })); + const module = createExpoIapVegaModule(service); + + const purchases = await module.getAvailableItems(); + + expect(purchases).toHaveLength(101); + expect(purchases[0]).toMatchObject({ + isAutoRenewing: true, + productId: 'sub_0', + }); + expect(service.getProductData).toHaveBeenCalledTimes(2); + expect(service.getProductData.mock.calls[0]?.[0].skus).toHaveLength(100); + expect(service.getProductData.mock.calls[1]?.[0].skus).toHaveLength(1); + }); + + it('excludes suspended purchases unless requested', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: [ + { + receiptId: 'deferred-sub', + sku: 'premium_monthly', + productType: 3, + isDeferred: true, + }, + ], + }); + const module = createExpoIapVegaModule(service); + + await expect(module.getAvailableItems()).resolves.toEqual([]); + + await expect( + module.getAvailableItems({includeSuspendedAndroid: true}), + ).resolves.toEqual([ + expect.objectContaining({ + id: 'deferred-sub', + isAutoRenewing: false, + isSuspendedAndroid: true, + purchaseState: 'pending', + }), + ]); + }); + + it('verifies Vega receipts through IAPKit Amazon payload', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json({ + isValid: true, + state: 'ENTITLED', + store: 'amazon', + }), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: 'kit-key', + amazon: { + receiptId: 'receipt-vega-1', + sandbox: true, + }, + }, + }), + ).resolves.toEqual({ + provider: 'iapkit', + iapkit: { + isValid: true, + state: 'entitled', + store: 'amazon', + }, + }); + + expect(service.getUserData).toHaveBeenCalledWith({ + fetchUserProfileAccessConsentStatus: false, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://kit.openiap.dev/v1/purchase/verify', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer kit-key', + 'Content-Type': 'application/json', + }), + }), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + store: 'amazon', + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + sandbox: true, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('supports custom IAPKit base URLs for Vega verification', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json({ + isValid: true, + state: 'ENTITLED', + store: 'amazon', + }), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: 'kit-key', + baseUrl: 'http://localhost:3100/', + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + } as Parameters[0] & { + iapkit: {baseUrl: string}; + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3100/v1/purchase/verify', + expect.any(Object), + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects mixed IAPKit payloads on the Amazon Vega adapter', async () => { + const service = createService(); + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + google: { + purchaseToken: 'google-token', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.DeveloperError, + message: + 'Amazon Vega IAPKit verification requires exactly one amazon payload.', + }); + }); + + it('wraps non-JSON IAPKit failures as receipt errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response('bad gateway', {status: 502}), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.ReceiptFailed, + message: 'HTTP 502', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('extracts nested JSON IAPKit failure messages', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + message: JSON.stringify({ + error: 'receipt no longer valid', + }), + }, + {status: 400}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.ReceiptFailed, + message: 'receipt no longer valid', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('extracts string entries from IAPKit error arrays', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + errors: ['receipt array failure'], + }, + {status: 400}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.ReceiptFailed, + message: 'receipt array failure', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects empty successful IAPKit responses as receipt errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response('', {status: 200}), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.ReceiptFailed, + message: 'IAPKit returned non-JSON response (HTTP 200).', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('treats successful IAPKit error payloads as receipt errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + errors: [ + { + code: 'BAD_RECEIPT', + message: 'bad receipt', + }, + ], + }, + {status: 200}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.ReceiptFailed, + message: 'bad receipt', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects malformed successful IAPKit payloads', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json(['not', 'an', 'object'], {status: 200}), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.ReceiptFailed, + message: 'IAPKit returned malformed response (HTTP 200).', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects successful IAPKit payloads missing required fields', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + state: 'ENTITLED', + store: 'amazon', + }, + {status: 200}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.ReceiptFailed, + message: 'IAPKit returned malformed response (HTTP 200).', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects successful IAPKit payloads for another store', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + isValid: true, + state: 'ENTITLED', + store: 'apple', + }, + {status: 200}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.ReceiptFailed, + message: 'IAPKit returned malformed response (HTTP 200).', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('wraps IAPKit network failures as network errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn(async () => { + throw new TypeError('network offline'); + }) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.NetworkError, + message: 'network offline', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('wraps IAPKit response body read failures as network errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn(async () => ({ + ok: true, + status: 200, + text: async () => { + throw new TypeError('body stream failed'); + }, + })) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.NetworkError, + message: 'body stream failed', + }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/libraries/expo-iap/src/amazon-devices-kepler.d.ts b/libraries/expo-iap/src/amazon-devices-kepler.d.ts new file mode 100644 index 00000000..1c1329ed --- /dev/null +++ b/libraries/expo-iap/src/amazon-devices-kepler.d.ts @@ -0,0 +1,14 @@ +declare module '@amazon-devices/keplerscript-appstore-iap-lib' { + export const PurchasingService: { + getProductData(request: {skus: string[]}): Promise; + getPurchaseUpdates(request: {reset: boolean}): Promise; + getUserData(request: { + fetchUserProfileAccessConsentStatus: boolean; + }): Promise; + notifyFulfillment(request: { + fulfillmentResult: number; + receiptId: string; + }): Promise; + purchase(request: {sku: string}): Promise; + }; +} diff --git a/libraries/expo-iap/src/index.kepler.ts b/libraries/expo-iap/src/index.kepler.ts new file mode 100644 index 00000000..3b4834e4 --- /dev/null +++ b/libraries/expo-iap/src/index.kepler.ts @@ -0,0 +1,280 @@ +import {getVegaIapModule} from './vega'; +import {ErrorCode} from './types'; +import type { + MutationField, + ProductQueryType, + Purchase, + PurchaseError, + PurchaseOptions, + PurchaseUpdatedListenerOptions, + QueryField, + RequestPurchasePropsByPlatforms, + RequestSubscriptionPropsByPlatforms, +} from './types'; + +export * from './types'; +export * from './vega'; +export * from './useIAP'; +export {kitApi, KitApiError} from './kit-api'; +export {connectWebhookStream, parseWebhookEventData} from './webhook-client'; + +export enum OpenIapEvent { + PurchaseUpdated = 'purchase-updated', + PurchaseError = 'purchase-error', + PromotedProductIOS = 'promoted-product-ios', + UserChoiceBillingAndroid = 'user-choice-billing-android', + DeveloperProvidedBillingAndroid = 'developer-provided-billing-android', + SubscriptionBillingIssue = 'subscription-billing-issue', +} + +export type ProductTypeInput = ProductQueryType | 'inapp'; + +export interface EventSubscription { + remove(): void; +} + +const getModule = () => { + const module = getVegaIapModule(); + if (!module) { + throw new Error( + 'Amazon Vega IAP module is unavailable. Install @amazon-devices/keplerscript-appstore-iap-lib in the Vega app target and build with the React Native for Vega kepler platform.', + ); + } + return module; +}; + +const unsupported = (feature: string): never => { + throw new Error(`${feature} is not supported on Amazon Vega.`); +}; + +const normalizeProductType = ( + type?: ProductTypeInput | null, +): ProductQueryType => { + if (type === 'subs' || type === 'all') return type; + return 'in-app'; +}; + +const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] => + purchases.map((purchase) => { + const platform = String(purchase.platform).toLowerCase(); + if (platform === purchase.platform) return purchase; + if (platform === 'android' || platform === 'ios') { + return {...purchase, platform}; + } + return purchase; + }); + +const getAndroidRequest = ( + request?: + | RequestPurchasePropsByPlatforms + | RequestSubscriptionPropsByPlatforms + | null, +) => request?.google ?? request?.android; + +const createPurchaseTokenError = (purchase: Purchase): Error => { + const error = new Error( + 'Purchase token is required to finish Amazon Vega transaction', + ) as Error & PurchaseError; + error.code = ErrorCode.DeveloperError; + error.productId = purchase.productId; + return error; +}; + +export const emitter = { + addListener( + eventName: OpenIapEvent, + listener: (payload: Purchase | PurchaseError) => void, + ): EventSubscription { + return getModule().addListener(eventName, listener); + }, + removeListener( + eventName: OpenIapEvent, + listener: (payload: Purchase | PurchaseError) => void, + ): void { + return getModule().removeListener(eventName, listener); + }, +}; + +export const purchaseUpdatedListener = ( + listener: (event: Purchase) => void, + options?: PurchaseUpdatedListenerOptions | null, +): EventSubscription => { + return getModule().addListener(OpenIapEvent.PurchaseUpdated, (purchase) => { + listener(purchase as Purchase); + }); +}; + +export const purchaseErrorListener = ( + listener: (error: PurchaseError) => void, +): EventSubscription => { + return getModule().addListener(OpenIapEvent.PurchaseError, (error) => { + listener(error as PurchaseError); + }); +}; + +export const initConnection: MutationField<'initConnection'> = async ( + config, +) => { + return getModule().initConnection(config ?? null); +}; + +export const endConnection: MutationField<'endConnection'> = async () => { + return getModule().endConnection(); +}; + +export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { + const {skus, type} = request; + return getModule().fetchProducts(normalizeProductType(type), skus); +}; + +export const requestPurchase: MutationField<'requestPurchase'> = async ( + args, +) => { + const androidRequest = getAndroidRequest(args.request); + if (!androidRequest?.skus?.length) { + throw new Error( + 'Invalid request for Amazon Vega. The `request.google.skus` or `request.android.skus` property is required and must be a non-empty array.', + ); + } + + return normalizePurchaseArray( + await getModule().requestPurchase({ + skuArr: androidRequest.skus, + type: normalizeProductType(args.type), + }), + ); +}; + +export const getAvailablePurchases: QueryField< + 'getAvailablePurchases' +> = async (options) => { + return normalizePurchaseArray( + await getModule().getAvailableItems((options ?? {}) as PurchaseOptions), + ); +}; + +export const finishTransaction: MutationField<'finishTransaction'> = async ({ + purchase, + isConsumable = false, +}) => { + const token = purchase.purchaseToken ?? undefined; + if (!token) { + throw createPurchaseTokenError(purchase); + } + + if (isConsumable) { + await getModule().consumePurchaseAndroid(token); + return; + } + + await getModule().acknowledgePurchaseAndroid(token); +}; + +export const restorePurchases: MutationField<'restorePurchases'> = async () => { + await getAvailablePurchases({ + includeSuspendedAndroid: false, + }); +}; + +export const getActiveSubscriptions: QueryField< + 'getActiveSubscriptions' +> = async (subscriptionIds) => { + return getModule().getActiveSubscriptions(subscriptionIds ?? null); +}; + +export const hasActiveSubscriptions: QueryField< + 'hasActiveSubscriptions' +> = async (subscriptionIds) => { + return getModule().hasActiveSubscriptions(subscriptionIds ?? null); +}; + +export const getStorefront: QueryField<'getStorefront'> = async () => { + return getModule().getStorefront(); +}; + +export const verifyPurchaseWithProvider: MutationField< + 'verifyPurchaseWithProvider' +> = async (options) => { + return getModule().verifyPurchaseWithProvider(options); +}; + +export const validateReceipt: MutationField<'validateReceipt'> = async () => + unsupported('validateReceipt'); + +export const verifyPurchase: MutationField<'verifyPurchase'> = validateReceipt; + +export const acknowledgePurchaseAndroid: MutationField< + 'acknowledgePurchaseAndroid' +> = async (purchaseToken) => { + await getModule().acknowledgePurchaseAndroid(purchaseToken); + return true; +}; + +export const consumePurchaseAndroid: MutationField< + 'consumePurchaseAndroid' +> = async (purchaseToken) => { + await getModule().consumePurchaseAndroid(purchaseToken); + return true; +}; + +export const acknowledgePurchase = acknowledgePurchaseAndroid; +export const consumePurchase = consumePurchaseAndroid; + +export const syncIOS: MutationField<'syncIOS'> = async () => + unsupported('syncIOS'); +export const getAppTransactionIOS: QueryField< + 'getAppTransactionIOS' +> = async () => null; +export const getPromotedProductIOS: QueryField< + 'getPromotedProductIOS' +> = async () => null; +export const requestPurchaseOnPromotedProductIOS = async (): Promise => + unsupported('requestPurchaseOnPromotedProductIOS'); +export const showManageSubscriptionsIOS: MutationField< + 'showManageSubscriptionsIOS' +> = async () => []; +export const presentCodeRedemptionSheetIOS: MutationField< + 'presentCodeRedemptionSheetIOS' +> = async () => false; +export const presentExternalPurchaseLinkIOS: MutationField< + 'presentExternalPurchaseLinkIOS' +> = async () => unsupported('presentExternalPurchaseLinkIOS'); + +export const deepLinkToSubscriptions: MutationField< + 'deepLinkToSubscriptions' +> = async () => unsupported('deepLinkToSubscriptions'); +export const openRedeemOfferCodeAndroid = async (): Promise => + unsupported('openRedeemOfferCodeAndroid'); + +export const promotedProductListenerIOS = (): EventSubscription => ({ + remove: () => {}, +}); +export const userChoiceBillingListenerAndroid = (): EventSubscription => ({ + remove: () => {}, +}); +export const developerProvidedBillingListenerAndroid = + (): EventSubscription => ({ + remove: () => {}, + }); +export const subscriptionBillingIssueListener = (): EventSubscription => ({ + remove: () => {}, +}); + +export const checkAlternativeBillingAvailabilityAndroid: MutationField< + 'checkAlternativeBillingAvailabilityAndroid' +> = async () => unsupported('checkAlternativeBillingAvailabilityAndroid'); +export const showAlternativeBillingDialogAndroid: MutationField< + 'showAlternativeBillingDialogAndroid' +> = async () => unsupported('showAlternativeBillingDialogAndroid'); +export const createAlternativeBillingTokenAndroid: MutationField< + 'createAlternativeBillingTokenAndroid' +> = async () => unsupported('createAlternativeBillingTokenAndroid'); +export const isBillingProgramAvailableAndroid: MutationField< + 'isBillingProgramAvailableAndroid' +> = async () => unsupported('isBillingProgramAvailableAndroid'); +export const launchExternalLinkAndroid: MutationField< + 'launchExternalLinkAndroid' +> = async () => unsupported('launchExternalLinkAndroid'); +export const createBillingProgramReportingDetailsAndroid: MutationField< + 'createBillingProgramReportingDetailsAndroid' +> = async () => unsupported('createBillingProgramReportingDetailsAndroid'); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 32a61f19..fc7c4367 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -3,6 +3,7 @@ import {Platform} from 'react-native'; // Internal modules import ExpoIapModule, {getNativeModule} from './ExpoIapModule'; +import {isVegaOS} from './vega'; import { isProductIOS, validateReceiptIOS, @@ -48,6 +49,7 @@ 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 { @@ -104,6 +106,9 @@ type NativePurchaseUpdatedOptionsModule = { const isStorePlatform = (): boolean => Platform.OS === 'ios' || Platform.OS === 'android'; +const isStoreRuntime = (): boolean => + Platform.OS === 'ios' || isAndroidStoreRuntime(); + const unsupportedPlatformError = (): Error => new Error(`Unsupported platform: ${Platform.OS}`); @@ -266,6 +271,10 @@ const normalizePurchasePlatform = (purchase: Purchase): Purchase => { const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] => purchases.map((purchase) => normalizePurchasePlatform(purchase)); +const isAndroidStoreRuntime = (): boolean => { + return Platform.OS === 'android' || isVegaOS(); +}; + export const purchaseUpdatedListener = ( listener: (event: Purchase) => void, options?: PurchaseUpdatedListenerOptions | null, @@ -572,7 +581,9 @@ export const subscriptionBillingIssueListener = ( * * @see {@link https://openiap.dev/docs/apis/init-connection} */ -export const initConnection: MutationField<'initConnection'> = async (config) => { +export const initConnection: MutationField<'initConnection'> = async ( + config, +) => { const result = await ExpoIapModule.initConnection(config ?? null); if ( result === true && @@ -681,7 +692,7 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { return castResult(filterIosItems(rawItems)); } - if (Platform.OS === 'android') { + if (isAndroidStoreRuntime()) { const rawItems = await ExpoIapModule.fetchProducts(native, skus); return castResult(filterAndroidItems(rawItems)); } @@ -718,6 +729,11 @@ export const getAvailablePurchases: QueryField< includeSuspendedAndroid: options?.includeSuspendedAndroid ?? false, }; + if (isVegaOS()) { + const purchases = await ExpoIapModule.getAvailableItems(normalizedOptions); + return normalizePurchaseArray(purchases as Purchase[]); + } + let purchases: Purchase[]; if (Platform.OS === 'ios') { @@ -769,7 +785,7 @@ export const getAvailablePurchases: QueryField< export const getActiveSubscriptions: QueryField< 'getActiveSubscriptions' > = async (subscriptionIds) => { - if (!isStorePlatform()) { + if (!isStoreRuntime()) { throw unsupportedPlatformError(); } @@ -799,7 +815,7 @@ export const getActiveSubscriptions: QueryField< export const hasActiveSubscriptions: QueryField< 'hasActiveSubscriptions' > = async (subscriptionIds) => { - if (!isStorePlatform()) { + if (!isStoreRuntime()) { throw unsupportedPlatformError(); } @@ -814,7 +830,7 @@ export const hasActiveSubscriptions: QueryField< * @see {@link https://openiap.dev/docs/apis/get-storefront} */ export const getStorefront: QueryField<'getStorefront'> = async () => { - if (Platform.OS !== 'ios' && Platform.OS !== 'android') { + if (Platform.OS !== 'ios' && !isAndroidStoreRuntime()) { return ''; } return ExpoIapModule.getStorefront(); @@ -930,7 +946,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( return canonical === 'subs' ? [] : null; } - if (Platform.OS === 'android') { + if (isAndroidStoreRuntime()) { if (isInAppPurchase) { const normalizedRequest = normalizeRequestProps( request as RequestPurchasePropsByPlatforms, @@ -1070,7 +1086,7 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({ return; } - if (Platform.OS === 'android') { + if (isAndroidStoreRuntime()) { const token = purchase.purchaseToken ?? undefined; if (!token) { @@ -1246,11 +1262,13 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( * provider: 'iapkit', * iapkit: { * apiKey: 'your-api-key', - * apple: { - * jws: purchase.purchaseToken // JWS from purchase - * }, - * google: { - * purchaseToken: purchase.purchaseToken + * // Choose exactly one store payload. + * // apple: { jws: purchase.purchaseToken }, + * // google: { purchaseToken: purchase.purchaseToken }, + * amazon: { + * userId: amazonUserId, + * receiptId: purchase.purchaseToken, + * sandbox: __DEV__, * } * } * }); @@ -1261,7 +1279,7 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( export const verifyPurchaseWithProvider: MutationField< 'verifyPurchaseWithProvider' > = async (options) => { - if (!isStorePlatform()) { + if (!isStoreRuntime()) { throw unsupportedPlatformError(); } diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 518f5ffd..2e3ddc63 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -511,7 +511,7 @@ export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product export type IapPlatform = 'ios' | 'android'; -export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon'; +export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon' | 'amazon'; /** Unified purchase states from IAPKit verification response. */ export type IapkitPurchaseState = 'entitled' | 'pending-acknowledgment' | 'pending' | 'canceled' | 'expired' | 'ready-to-consume' | 'consumed' | 'unknown' | 'inauthentic'; @@ -1585,7 +1585,8 @@ export type RequestPurchaseProps = * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ export interface RequestPurchasePropsByPlatforms { @@ -1679,7 +1680,8 @@ export interface RequestSubscriptionIosProps { * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ export interface RequestSubscriptionPropsByPlatforms { @@ -1693,6 +1695,15 @@ export interface RequestSubscriptionPropsByPlatforms { ios?: (RequestSubscriptionIosProps | null); } +export interface RequestVerifyPurchaseWithIapkitAmazonProps { + /** Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). */ + receiptId: string; + /** Use Amazon RVS Cloud Sandbox for App Tester receipts. */ + sandbox?: (boolean | null); + /** Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). */ + userId?: (string | null); +} + export interface RequestVerifyPurchaseWithIapkitAppleProps { /** The JWS token returned with the purchase response. */ jws: string; @@ -1708,8 +1719,11 @@ export interface RequestVerifyPurchaseWithIapkitGoogleProps { * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) */ export interface RequestVerifyPurchaseWithIapkitProps { + /** Amazon Appstore verification parameters. */ + amazon?: (RequestVerifyPurchaseWithIapkitAmazonProps | null); /** API key used for the Authorization header (Bearer {apiKey}). */ apiKey?: (string | null); /** Apple App Store verification parameters. */ diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts index abe03723..64ffdd90 100644 --- a/libraries/expo-iap/src/useIAP.ts +++ b/libraries/expo-iap/src/useIAP.ts @@ -60,6 +60,20 @@ import { isRecoverableError, } from './utils/errorMapping'; +const PURCHASE_DELIVERY_DEDUP_WINDOW_MS = 30_000; + +function getPurchaseDeliveryKey(purchase: Purchase): string { + return [ + purchase.store ?? '', + purchase.productId ?? '', + purchase.purchaseToken ?? + purchase.transactionId ?? + purchase.id ?? + purchase.transactionDate ?? + '', + ].join(':'); +} + type UseIap = { connected: boolean; products: Product[]; @@ -169,6 +183,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { const optionsRef = useRef(options); const connectedRef = useRef(false); + const deliveredPurchaseKeysRef = useRef(new Set()); // Helper function to merge arrays with duplicate checking const mergeWithDuplicateCheck = useCallback( @@ -250,6 +265,19 @@ export function useIAP(options?: UseIAPOptions): UseIap { [], ); + const markPurchaseDelivered = useCallback((purchase: Purchase): boolean => { + const key = getPurchaseDeliveryKey(purchase); + if (deliveredPurchaseKeysRef.current.has(key)) { + return false; + } + + deliveredPurchaseKeysRef.current.add(key); + setTimeout(() => { + deliveredPurchaseKeysRef.current.delete(key); + }, PURCHASE_DELIVERY_DEDUP_WINDOW_MS); + return true; + }, []); + // Helper function to invoke onError callback const invokeOnError = useCallback((error: unknown) => { if (optionsRef.current?.onError) { @@ -495,6 +523,25 @@ export function useIAP(options?: UseIAPOptions): UseIap { [toPurchaseInput], ); + const refreshSubscriptionStatus = useCallback( + async (productId: string) => { + try { + if (subscriptionsRefState.current.some((sub) => sub.id === productId)) { + await fetchProductsInternal({skus: [productId], type: 'subs'}); + await getAvailablePurchasesInternal(); + await getActiveSubscriptionsInternal(); + } + } catch (error) { + ExpoIapConsole.warn('Failed to refresh subscription status:', error); + } + }, + [ + fetchProductsInternal, + getAvailablePurchasesInternal, + getActiveSubscriptionsInternal, + ], + ); + /** * Initiate a purchase or subscription flow. The result is delivered through * `purchaseUpdatedListener` — NOT the return value. @@ -523,29 +570,28 @@ export function useIAP(options?: UseIAPOptions): UseIap { * @see {@link https://openiap.dev/docs/apis/request-purchase} */ const requestPurchaseWithReset = useCallback( - (requestObj: MutationRequestPurchaseArgs) => { - return requestPurchaseInternal(requestObj); - }, - [], - ); + async (requestObj: MutationRequestPurchaseArgs) => { + const purchaseResult = await requestPurchaseInternal(requestObj); + const purchases = Array.isArray(purchaseResult) + ? purchaseResult + : purchaseResult + ? [purchaseResult] + : []; + + for (const purchase of purchases ?? []) { + if (!markPurchaseDelivered(purchase)) { + continue; + } - const refreshSubscriptionStatus = useCallback( - async (productId: string) => { - try { - if (subscriptionsRefState.current.some((sub) => sub.id === productId)) { - await fetchProductsInternal({skus: [productId], type: 'subs'}); - await getAvailablePurchasesInternal(); - await getActiveSubscriptionsInternal(); + await refreshSubscriptionStatus(purchase.productId); + if (optionsRef.current?.onPurchaseSuccess) { + optionsRef.current.onPurchaseSuccess(purchase); } - } catch (error) { - ExpoIapConsole.warn('Failed to refresh subscription status:', error); } + + return purchaseResult; }, - [ - fetchProductsInternal, - getAvailablePurchasesInternal, - getActiveSubscriptionsInternal, - ], + [markPurchaseDelivered, refreshSubscriptionStatus], ); /** @@ -626,6 +672,10 @@ export function useIAP(options?: UseIAPOptions): UseIap { // Register purchase update listener BEFORE initConnection to avoid race conditions. 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. @@ -645,7 +695,12 @@ export function useIAP(options?: UseIAPOptions): UseIap { return; // Ignore initialization error before connected } const friendly = getUserFriendlyErrorMessage(error); - if (!isUserCancelledError(error) && !isRecoverableError(error)) { + if ( + error?.code !== ErrorCode.AlreadyOwned && + error?.code !== ErrorCode.ServiceTimeout && + !isUserCancelledError(error) && + !isRecoverableError(error) + ) { ExpoIapConsole.warn('[useIAP] Purchase error:', friendly); } @@ -694,7 +749,12 @@ export function useIAP(options?: UseIAPOptions): UseIap { subscriptionsRef.current.purchaseUpdate = undefined; subscriptionsRef.current.promotedProductIOS = undefined; } - }, [buildConnectionConfig, refreshSubscriptionStatus, invokeOnError]); + }, [ + buildConnectionConfig, + markPurchaseDelivered, + refreshSubscriptionStatus, + invokeOnError, + ]); // Manual reconnect method for when the initial auto-connect fails. // Re-runs initConnection and updates the connected state. @@ -711,6 +771,10 @@ export function useIAP(options?: UseIAPOptions): UseIap { if (!subscriptionsRef.current.purchaseUpdate) { subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener( async (purchase: Purchase) => { + if (!markPurchaseDelivered(purchase)) { + return; + } + await refreshSubscriptionStatus(purchase.productId); if (optionsRef.current?.onPurchaseSuccess) { @@ -742,7 +806,12 @@ export function useIAP(options?: UseIAPOptions): UseIap { invokeOnError(error); return false; } - }, [buildConnectionConfig, refreshSubscriptionStatus, invokeOnError]); + }, [ + buildConnectionConfig, + markPurchaseDelivered, + refreshSubscriptionStatus, + invokeOnError, + ]); useEffect(() => { initIapWithSubscriptions(); diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts new file mode 100644 index 00000000..4c0645c6 --- /dev/null +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -0,0 +1,1313 @@ +import {ErrorCode} from './types'; +import type { + ActiveSubscription, + IapkitPurchaseState, + Product, + ProductSubscription, + Purchase, + PurchaseOptions, + VerifyPurchaseWithProviderProps, + VerifyPurchaseWithProviderResult, +} from './types'; + +type ResponseOperation = + | 'product-data' + | 'purchase' + | 'purchase-updates' + | 'user-data' + | 'notify-fulfillment'; + +const IAPKIT_VERIFY_TIMEOUT_MS = 10_000; +const MAX_IAPKIT_ERROR_DEPTH = 5; +const MAX_PRODUCT_DATA_BATCH_SIZE = 100; +const MAX_PURCHASE_UPDATE_PAGES = 100; +const NOTIFY_FULFILLMENT_MAX_ATTEMPTS = 15; +const NOTIFY_FULFILLMENT_RETRY_DELAY_MS = 1_000; +const PURCHASE_UPDATES_MAX_ATTEMPTS = 5; +const PURCHASE_UPDATES_RETRY_DELAY_MS = 1_000; +const PURCHASE_RECOVERY_CLOCK_SKEW_MS = 5_000; + +type VegaListener = (payload: any) => void; + +interface VegaPurchaseErrorPayload { + code: ErrorCode; + debugMessage?: string; + message: string; + productId?: string; + responseCode?: number; +} + +interface RecoverPurchasesOptions { + minPurchaseDateMs?: number; +} + +interface VegaPrice { + priceCurrencyCode?: string | null; + priceStr?: string | null; + valueInMicros?: bigint | number | string | null; +} + +interface VegaProduct { + description?: string | null; + freeTrialPeriod?: string | null; + itemType?: unknown; + price?: VegaPrice | number | string | null; + productType?: unknown; + sku?: string | null; + subscriptionBase?: string | null; + subscriptionParent?: string | null; + subscriptionPeriod?: string | null; + term?: string | null; + title?: string | null; +} + +interface VegaReceipt { + cancelDate?: Date | number | string | null; + deferredDate?: Date | number | string | null; + isCancelled?: boolean | null; + isDeferred?: boolean | null; + productType?: unknown; + purchaseDate?: Date | number | string | null; + receiptId?: string | null; + sku?: string | null; + termSku?: string | null; +} + +interface VegaUserData { + countryCode?: string | null; + marketplace?: string | null; + userId?: string | null; +} + +interface VegaResponse { + responseCode?: unknown; +} + +interface VegaProductDataResponse extends VegaResponse { + productData?: Map | Record | null; +} + +interface VegaPurchaseResponse extends VegaResponse { + receipt?: VegaReceipt | null; + userData?: VegaUserData | null; +} + +interface VegaPurchaseUpdatesResponse extends VegaResponse { + hasMore?: boolean | null; + receiptList?: VegaReceipt[] | null; + userData?: VegaUserData | null; +} + +interface VegaUserDataResponse extends VegaResponse { + userData?: VegaUserData | null; +} + +interface VegaUserDataRequest { + fetchUserProfileAccessConsentStatus: boolean; +} + +export interface VegaPurchasingService { + getProductData(request: {skus: string[]}): Promise; + getPurchaseUpdates(request: { + reset: boolean; + }): Promise; + getUserData(request: VegaUserDataRequest): Promise; + notifyFulfillment(request: { + fulfillmentResult: number; + receiptId: string; + }): Promise; + purchase(request: {sku: string}): Promise; +} + +export interface ExpoIapVegaModule { + ERROR_CODES: Record; + acknowledgePurchaseAndroid(purchaseToken: string): Promise; + addListener(eventName: string, listener: VegaListener): {remove: () => void}; + consumePurchaseAndroid(purchaseToken: string): Promise; + endConnection(): Promise; + fetchProducts( + type: string, + skus: string[], + ): Promise<(Product | ProductSubscription)[]>; + getActiveSubscriptions( + subscriptionIds?: string[] | null, + ): Promise; + getAvailableItems(options?: PurchaseOptions): Promise; + getStorefront(): Promise; + hasActiveSubscriptions(subscriptionIds?: string[] | null): Promise; + initConnection(config?: unknown): Promise; + removeListener(eventName: string, listener: VegaListener): void; + requestPurchase(params: { + skuArr?: string[]; + type?: string; + }): Promise; + verifyPurchaseWithProvider( + options: VerifyPurchaseWithProviderProps, + ): Promise; +} + +const PRODUCT_TYPE_SUBSCRIPTION = 3; +const FULFILLMENT_RESULT_FULFILLED = 1; +const RESPONSE_SUCCESS = 1; +const PURCHASE_RESPONSE_SUCCESS = 0; +const IAPKIT_DEFAULT_BASE_URL = 'https://kit.openiap.dev'; +const IAPKIT_VERIFY_PATH = '/v1/purchase/verify'; +const VEGA_PARSER_ERROR_MESSAGES = [ + 'Cannot convert undefined value to object', + 'userId is not found while parsing Json', +]; + +function createVegaError( + code: ErrorCode, + message: string, + responseCode?: unknown, + productId?: string, +): Error { + const error = new Error(message) as Error & { + code?: ErrorCode; + debugMessage?: string; + productId?: string; + responseCode?: number; + }; + error.code = code; + error.debugMessage = message; + error.productId = productId; + if (typeof responseCode === 'number') { + error.responseCode = responseCode; + } + return error; +} + +function toPurchaseErrorPayload( + error: unknown, + fallbackMessage: string, + productId?: string, +): VegaPurchaseErrorPayload { + if (error instanceof Error) { + const errorWithFields = error as Error & { + code?: ErrorCode; + debugMessage?: string; + productId?: string; + responseCode?: number; + }; + return { + code: errorWithFields.code ?? ErrorCode.PurchaseError, + debugMessage: errorWithFields.debugMessage, + message: error.message || fallbackMessage, + productId: errorWithFields.productId ?? productId, + responseCode: errorWithFields.responseCode, + }; + } + + return { + code: ErrorCode.PurchaseError, + message: fallbackMessage, + productId, + }; +} + +function responseCodeName(responseCode: unknown): string { + return typeof responseCode === 'string' ? responseCode.toUpperCase() : ''; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function isSuccess( + operation: ResponseOperation, + responseCode: unknown, +): boolean { + if (typeof responseCode === 'number') { + return operation === 'purchase' + ? responseCode === PURCHASE_RESPONSE_SUCCESS + : responseCode === RESPONSE_SUCCESS; + } + + const name = responseCodeName(responseCode); + return name === 'SUCCESSFUL' || name === 'SUCCESS' || name === 'OK'; +} + +function mapErrorCode( + operation: ResponseOperation, + responseCode: unknown, +): ErrorCode { + const name = responseCodeName(responseCode); + if (name.includes('ALREADY_PURCHASED')) return ErrorCode.AlreadyOwned; + if (name.includes('INVALID_SKU')) return ErrorCode.SkuNotFound; + if (name.includes('NOT_SUPPORTED')) return ErrorCode.FeatureNotSupported; + if (name.includes('PENDING')) return ErrorCode.Pending; + if (name.includes('FAILED')) { + return operation === 'purchase' + ? ErrorCode.UserCancelled + : ErrorCode.ServiceError; + } + + if (typeof responseCode === 'number') { + if (operation === 'purchase') { + if (responseCode === 1) return ErrorCode.AlreadyOwned; + if (responseCode === 2) return ErrorCode.SkuNotFound; + if (responseCode === 3) return ErrorCode.FeatureNotSupported; + if (responseCode === 4) return ErrorCode.UserCancelled; + } + if (operation !== 'purchase' && responseCode === 2) { + return ErrorCode.FeatureNotSupported; + } + if (operation !== 'purchase' && responseCode === 3) { + return ErrorCode.ServiceError; + } + if (operation !== 'purchase' && responseCode === 4) { + return ErrorCode.ServiceError; + } + } + + if (operation === 'product-data') return ErrorCode.QueryProduct; + if (operation === 'user-data') return ErrorCode.InitConnection; + return ErrorCode.PurchaseError; +} + +function shouldRetryResponse( + operation: ResponseOperation, + responseCode: unknown, +): boolean { + if (isSuccess(operation, responseCode)) return false; + if (operation === 'purchase') return false; + if (typeof responseCode === 'number') return responseCode === 3; + return responseCodeName(responseCode).includes('FAILED'); +} + +function shouldRecoverPurchaseResponse(responseCode: unknown): boolean { + if (typeof responseCode === 'number') { + return responseCode === 1 || responseCode === 4; + } + const name = responseCodeName(responseCode); + return name.includes('ALREADY_PURCHASED') || name.includes('FAILED'); +} + +function ensureSuccessful( + operation: ResponseOperation, + response: VegaResponse | null | undefined, + message: string, + productId?: string, +): void { + const responseCode = response?.responseCode; + if (isSuccess(operation, responseCode)) return; + throw createVegaError( + mapErrorCode(operation, responseCode), + `${message}. Amazon Vega responseCode=${String(responseCode ?? 'unknown')}`, + responseCode, + productId, + ); +} + +function toTimestamp(value: unknown): number { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; + } + return 0; +} + +function priceNumberToMicros(value: number): number { + return Math.trunc(Math.abs(value) < 10_000 ? value * 1_000_000 : value); +} + +function toPriceAmountMicros(value: unknown): string { + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value).toString(); + } + if (typeof value === 'string' && value.length > 0) return value; + return '0'; +} + +function getPriceObject(product: VegaProduct): VegaPrice { + return product.price != null && + typeof product.price === 'object' && + !Array.isArray(product.price) + ? product.price + : {}; +} + +function getPriceAmountMicros(product: VegaProduct): unknown { + if (typeof product.price === 'number' && Number.isFinite(product.price)) { + return priceNumberToMicros(product.price); + } + return getPriceObject(product).valueInMicros; +} + +function microsToPrice(value: unknown): number | null { + const micros = + typeof value === 'bigint' ? Number(value) : Number(value ?? Number.NaN); + if (!Number.isFinite(micros)) return null; + return micros / 1_000_000; +} + +function getPrice(product: VegaProduct): number | null { + if (typeof product.price === 'number' && Number.isFinite(product.price)) { + return Math.abs(product.price) < 10_000 + ? product.price + : product.price / 1_000_000; + } + return microsToPrice(getPriceObject(product).valueInMicros); +} + +function getDisplayPrice(product: VegaProduct): string { + const price = product.price; + if (typeof price === 'number' && Number.isFinite(price)) { + const value = Math.abs(price) < 10_000 ? price : price / 1_000_000; + return value.toFixed(2); + } + if (typeof price === 'string') return price; + return getPriceObject(product).priceStr ?? ''; +} + +function getCurrency(product: VegaProduct): string { + return getPriceObject(product).priceCurrencyCode ?? ''; +} + +function isSubscription(productType: unknown): boolean { + if (typeof productType === 'number') + return productType === PRODUCT_TYPE_SUBSCRIPTION; + if (typeof productType === 'string') { + return productType.toUpperCase().includes('SUBSCRIPTION'); + } + return false; +} + +function productTypeToOpenIap(productType: unknown): 'in-app' | 'subs' { + return isSubscription(productType) ? 'subs' : 'in-app'; +} + +function getProductType(product: VegaProduct): unknown { + return product.productType ?? product.itemType; +} + +function getSubscriptionPeriod(product: VegaProduct): string { + if (product.subscriptionPeriod) return product.subscriptionPeriod; + + const term = product.term?.toLowerCase() ?? ''; + if (term.includes('year')) return 'P1Y'; + if (term.includes('month')) return 'P1M'; + if (term.includes('week')) return 'P1W'; + if (term.includes('day')) return 'P1D'; + return ''; +} + +function getReceiptSku(receipt: VegaReceipt): string { + return receipt.sku ?? receipt.termSku ?? ''; +} + +function getCachedProductType( + receipt: VegaReceipt, + productTypesBySku: Map, + fallbackSku?: string, +): unknown { + const sku = getReceiptSku(receipt) || fallbackSku || ''; + return sku ? productTypesBySku.get(sku) : undefined; +} + +function productDataToArray( + productData?: Map | Record | null, +): VegaProduct[] { + if (!productData) return []; + if (productData instanceof Map) { + return Array.from(productData.entries()).map(([sku, product]) => ({ + ...product, + sku: product.sku ?? sku, + })); + } + return Object.entries(productData).map(([sku, product]) => ({ + ...product, + sku: product.sku ?? sku, + })); +} + +function chunkArray(items: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +} + +function createUserDataRequest(): VegaUserDataRequest { + return { + fetchUserProfileAccessConsentStatus: false, + }; +} + +function isVegaParserError(error: unknown): boolean { + return ( + error instanceof Error && + VEGA_PARSER_ERROR_MESSAGES.some((message) => + error.message.includes(message), + ) + ); +} + +function createPricingPhase(product: VegaProduct) { + return { + billingCycleCount: 0, + billingPeriod: getSubscriptionPeriod(product), + formattedPrice: getDisplayPrice(product), + priceAmountMicros: toPriceAmountMicros(getPriceAmountMicros(product)), + priceCurrencyCode: getCurrency(product), + recurrenceMode: 1, + }; +} + +function createSubscriptionOffer(product: VegaProduct) { + const sku = product.sku ?? ''; + const pricingPhase = createPricingPhase(product); + return { + basePlanId: sku, + offerId: null, + offerTags: [], + offerToken: '', + pricingPhases: { + pricingPhaseList: [pricingPhase], + }, + }; +} + +function createStandardizedSubscriptionOffer(product: VegaProduct) { + const pricingPhase = createPricingPhase(product); + const sku = product.sku ?? ''; + return { + basePlanIdAndroid: sku, + currency: getCurrency(product), + displayPrice: getDisplayPrice(product), + id: sku, + offerTagsAndroid: [], + offerTokenAndroid: '', + paymentMode: 'pay-as-you-go' as const, + period: null, + price: getPrice(product) ?? 0, + pricingPhasesAndroid: { + pricingPhaseList: [pricingPhase], + }, + type: 'introductory' as const, + }; +} + +function mapProduct(product: VegaProduct): Product | ProductSubscription { + const sku = product.sku ?? ''; + const type = productTypeToOpenIap(getProductType(product)); + const base = { + id: sku, + title: product.title ?? sku, + description: product.description ?? '', + displayName: product.title ?? sku, + displayPrice: getDisplayPrice(product), + currency: getCurrency(product), + price: getPrice(product), + debugDescription: null, + platform: 'android' as const, + nameAndroid: product.title ?? sku, + oneTimePurchaseOfferDetailsAndroid: null, + productStatusAndroid: 'ok' as const, + discountOffers: null, + }; + + if (type === 'subs') { + return { + ...base, + type, + subscriptionOfferDetailsAndroid: [createSubscriptionOffer(product)], + subscriptionOffers: [createStandardizedSubscriptionOffer(product)], + }; + } + + return { + ...base, + type, + subscriptionOfferDetailsAndroid: null, + subscriptionOffers: null, + }; +} + +function mapReceipt( + receipt: VegaReceipt, + fallbackProductType?: unknown, + productIdOverride?: string, +): Purchase { + const receiptId = receipt.receiptId ?? ''; + const productId = productIdOverride ?? getReceiptSku(receipt); + const type = productTypeToOpenIap(receipt.productType ?? fallbackProductType); + const isPending = Boolean(receipt.isDeferred); + const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); + const isActive = !isCanceled && !isPending; + + return { + id: receiptId, + productId, + transactionDate: toTimestamp(receipt.purchaseDate), + purchaseToken: receiptId, + platform: 'android', + store: 'amazon', + quantity: 1, + purchaseState: isPending ? 'pending' : isActive ? 'purchased' : 'unknown', + isAutoRenewing: type === 'subs' && isActive, + transactionId: receiptId, + autoRenewingAndroid: type === 'subs' && isActive, + dataAndroid: JSON.stringify(receipt), + signatureAndroid: null, + isAcknowledgedAndroid: false, + packageNameAndroid: null, + obfuscatedAccountIdAndroid: null, + obfuscatedProfileIdAndroid: null, + developerPayloadAndroid: null, + isSuspendedAndroid: Boolean(receipt.isDeferred), + }; +} + +export function createExpoIapVegaModule( + service: VegaPurchasingService, +): ExpoIapVegaModule { + const productTypesBySku = new Map(); + const subscriptionBasesBySku = new Map(); + const subscriptionParentsBySku = new Map(); + const listenersByEventName = new Map>(); + let cachedUserData: VegaUserData | null = null; + + const emit = (eventName: string, payload: unknown): void => { + for (const listener of listenersByEventName.get(eventName) ?? []) { + listener(payload); + } + }; + + const cacheProductMetadata = (product: VegaProduct): void => { + if (!product.sku) return; + + const productType = getProductType(product); + productTypesBySku.set(product.sku, productType); + + if (product.subscriptionBase) { + subscriptionBasesBySku.set(product.sku, product.subscriptionBase); + productTypesBySku.set(product.subscriptionBase, productType); + } + if (product.subscriptionParent) { + subscriptionParentsBySku.set(product.sku, product.subscriptionParent); + productTypesBySku.set(product.subscriptionParent, productType); + } + }; + + const receiptMatchesRequestedSku = ( + receipt: VegaReceipt, + sku: string, + ): boolean => { + const receiptSku = getReceiptSku(receipt); + if (!receiptSku) return false; + if (receiptSku === sku || receipt.termSku === sku) return true; + if (receiptSku === subscriptionBasesBySku.get(sku)) return true; + if (receiptSku === subscriptionParentsBySku.get(sku)) return true; + return receiptSku === `${sku}.base`; + }; + + const resolveReceiptProductId = ( + receipt: VegaReceipt, + productIdOverride?: string, + ): string => { + if (productIdOverride) return productIdOverride; + + const receiptSku = getReceiptSku(receipt); + if (!receiptSku) return ''; + + for (const [productSku, subscriptionBase] of subscriptionBasesBySku) { + if (receiptSku === subscriptionBase) return productSku; + } + + for (const [productSku, subscriptionParent] of subscriptionParentsBySku) { + if (receiptSku === subscriptionParent) return productSku; + } + + if (receiptSku.endsWith('.base')) { + const parentSku = receiptSku.slice(0, -'.base'.length); + if (productTypesBySku.has(parentSku)) return parentSku; + } + + return receiptSku; + }; + + const getUserData = async (): Promise => { + let response: VegaUserDataResponse; + try { + response = await service.getUserData(createUserDataRequest()); + } catch (error) { + if (isVegaParserError(error)) { + return null; + } + throw error; + } + ensureSuccessful('user-data', response, 'Failed to fetch Amazon user data'); + cachedUserData = response.userData ?? null; + return cachedUserData; + }; + + const getStorefront = async (): Promise => { + await getUserData(); + return cachedUserData?.marketplace ?? cachedUserData?.countryCode ?? ''; + }; + + const getPurchaseUpdateReceipts = async (): Promise => { + const receipts: VegaReceipt[] = []; + let reset = true; + let hasMore = false; + let pageCount = 0; + + do { + if (pageCount >= MAX_PURCHASE_UPDATE_PAGES) { + throw createVegaError( + ErrorCode.ServiceError, + 'Amazon Vega purchase updates exceeded the pagination limit.', + ); + } + pageCount++; + + let response: VegaPurchaseUpdatesResponse | null = null; + for ( + let attempt = 1; + attempt <= PURCHASE_UPDATES_MAX_ATTEMPTS; + attempt += 1 + ) { + try { + response = await service.getPurchaseUpdates({reset}); + } catch (error) { + if (isVegaParserError(error)) { + return receipts; + } + throw error; + } + + if ( + !response || + !shouldRetryResponse('purchase-updates', response.responseCode) || + attempt === PURCHASE_UPDATES_MAX_ATTEMPTS + ) { + break; + } + await delay(PURCHASE_UPDATES_RETRY_DELAY_MS); + } + if (!response) { + throw createVegaError( + ErrorCode.ServiceError, + 'Amazon Vega purchase updates returned no response.', + ); + } + ensureSuccessful( + 'purchase-updates', + response, + 'Failed to fetch Amazon purchase updates', + ); + cachedUserData = response.userData ?? cachedUserData; + receipts.push(...(response.receiptList ?? [])); + hasMore = Boolean(response.hasMore); + reset = false; + } while (hasMore); + + return receipts; + }; + + const getProductData = async ( + skus: string[], + message: string, + ): Promise => { + const products: VegaProduct[] = []; + for (const batch of chunkArray(skus, MAX_PRODUCT_DATA_BATCH_SIZE)) { + const response = await service.getProductData({skus: batch}); + ensureSuccessful('product-data', response, message); + products.push(...productDataToArray(response.productData)); + } + return products; + }; + + const hydrateProductTypesForReceipts = async ( + receipts: VegaReceipt[], + ): Promise => { + const missingSkus = new Set(); + + for (const receipt of receipts) { + const sku = getReceiptSku(receipt); + if (!sku) continue; + if (receipt.productType != null) { + productTypesBySku.set(sku, receipt.productType); + continue; + } + + const resolvedSku = resolveReceiptProductId(receipt); + const resolvedProductType = productTypesBySku.get(resolvedSku); + if (resolvedProductType != null) { + productTypesBySku.set(sku, resolvedProductType); + } else if (!productTypesBySku.has(sku)) { + missingSkus.add(sku); + } + } + + if (missingSkus.size === 0) return; + + let products: VegaProduct[]; + try { + products = await getProductData( + Array.from(missingSkus), + 'Failed to fetch Amazon Vega product data for purchase updates', + ); + } catch (error) { + if (isVegaParserError(error)) { + return; + } + throw error; + } + + for (const product of products) { + cacheProductMetadata(product); + } + }; + + const getAvailableItems = async ( + options?: PurchaseOptions, + ): Promise => { + const includeSuspended = Boolean(options?.includeSuspendedAndroid ?? false); + const receipts = await getPurchaseUpdateReceipts(); + await hydrateProductTypesForReceipts(receipts); + return receipts + .filter((receipt) => { + const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); + if (isCanceled) return false; + return includeSuspended || !receipt.isDeferred; + }) + .map((receipt) => + mapReceipt( + receipt, + getCachedProductType(receipt, productTypesBySku), + resolveReceiptProductId(receipt), + ), + ); + }; + + const finishReceipt = async (purchaseToken: string): Promise => { + if (!purchaseToken) { + throw createVegaError( + ErrorCode.DeveloperError, + 'purchaseToken is required to finish an Amazon Vega transaction.', + ); + } + + let lastResponse: VegaResponse | null = null; + for ( + let attempt = 1; + attempt <= NOTIFY_FULFILLMENT_MAX_ATTEMPTS; + attempt += 1 + ) { + const response = await service.notifyFulfillment({ + fulfillmentResult: FULFILLMENT_RESULT_FULFILLED, + receiptId: purchaseToken, + }); + if (isSuccess('notify-fulfillment', response?.responseCode)) return; + + lastResponse = response; + if (attempt < NOTIFY_FULFILLMENT_MAX_ATTEMPTS) { + await delay(NOTIFY_FULFILLMENT_RETRY_DELAY_MS); + } + } + + ensureSuccessful( + 'notify-fulfillment', + lastResponse, + 'Failed to notify Amazon Vega fulfillment', + ); + }; + + const recoverFulfillablePurchases = async ( + sku: string, + fallbackProductType?: unknown, + options?: RecoverPurchasesOptions, + ): Promise<{requestedPurchases: Purchase[]}> => { + const receipts = await getPurchaseUpdateReceipts(); + await hydrateProductTypesForReceipts(receipts); + const requestedPurchases: Purchase[] = []; + + for (const receipt of receipts) { + const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); + if (isCanceled || receipt.isDeferred) continue; + + const purchaseTimestamp = toTimestamp(receipt.purchaseDate); + if ( + options?.minPurchaseDateMs != null && + (purchaseTimestamp === 0 || purchaseTimestamp < options.minPurchaseDateMs) + ) { + continue; + } + + const matchesRequestedSku = receiptMatchesRequestedSku(receipt, sku); + const purchase = mapReceipt( + receipt, + receipt.productType ?? + getCachedProductType(receipt, productTypesBySku, sku) ?? + (matchesRequestedSku ? fallbackProductType : undefined), + resolveReceiptProductId(receipt, matchesRequestedSku ? sku : undefined), + ); + if (matchesRequestedSku) { + requestedPurchases.push(purchase); + } + emit('purchase-updated', purchase); + } + + return {requestedPurchases}; + }; + + const verifyWithIapkit = async ( + options: VerifyPurchaseWithProviderProps, + ): Promise => { + type IapkitEndpointOptions = NonNullable< + VerifyPurchaseWithProviderProps['iapkit'] + > & { + baseUrl?: string | null; + }; + + function iapkitVerifyUrl( + iapkit: VerifyPurchaseWithProviderProps['iapkit'], + ): string { + const endpointOptions = iapkit as + | IapkitEndpointOptions + | null + | undefined; + const baseUrl = + typeof endpointOptions?.baseUrl === 'string' && + endpointOptions.baseUrl.trim().length > 0 + ? endpointOptions.baseUrl.trim() + : IAPKIT_DEFAULT_BASE_URL; + return `${baseUrl.replace(/\/+$/, '')}${IAPKIT_VERIFY_PATH}`; + } + + function normalizeIapkitState(state: unknown): IapkitPurchaseState { + const normalized = + typeof state === 'string' + ? state.toLowerCase().replace(/_/g, '-') + : 'unknown'; + const states = new Set([ + 'entitled', + 'pending-acknowledgment', + 'pending', + 'canceled', + 'expired', + 'ready-to-consume', + 'consumed', + 'unknown', + 'inauthentic', + ]); + return states.has(normalized as IapkitPurchaseState) + ? (normalized as IapkitPurchaseState) + : 'unknown'; + } + + function extractIapkitErrorMessage( + json: unknown, + depth = 0, + ): string | null { + if (depth > MAX_IAPKIT_ERROR_DEPTH) return null; + if (!json || typeof json !== 'object') return null; + const record = json as Record; + function extractStringMessage(value: string): string { + if (depth >= MAX_IAPKIT_ERROR_DEPTH) return value; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' + ? (extractIapkitErrorMessage(parsed, depth + 1) ?? value) + : value; + } catch { + return value; + } + } + + const details = record.details; + if (details && typeof details === 'object') { + const originalError = (details as Record) + .originalError; + if (typeof originalError === 'string') { + return extractStringMessage(originalError); + } + } + + const errors = record.errors; + if (Array.isArray(errors) && errors.length > 0) { + const firstError = errors[0]; + return typeof firstError === 'string' + ? extractStringMessage(firstError) + : extractIapkitErrorMessage(firstError, depth + 1); + } + + if (typeof record.message === 'string') { + return extractStringMessage(record.message); + } + if (typeof record.error === 'string') { + return extractStringMessage(record.error); + } + return null; + } + + function parseIapkitJsonResponse(text: string): unknown | null { + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + return null; + } + } + + function isIapkitResultObject( + json: unknown, + ): json is Record { + return Boolean(json) && typeof json === 'object' && !Array.isArray(json); + } + + function hasIapkitErrors(json: unknown): boolean { + if (!isIapkitResultObject(json)) return false; + const errors = json.errors; + return Array.isArray(errors) && errors.length > 0; + } + + function readIapkitResult( + json: Record, + status: number, + ): { + isValid: boolean; + state: IapkitPurchaseState; + } { + if ( + typeof json.isValid !== 'boolean' || + typeof json.state !== 'string' || + json.store !== 'amazon' + ) { + throw createVegaError( + ErrorCode.ReceiptFailed, + `IAPKit returned malformed response (HTTP ${status}).`, + ); + } + + return { + isValid: json.isValid, + state: normalizeIapkitState(json.state), + }; + } + + if (options.provider !== 'iapkit') { + throw createVegaError( + ErrorCode.FeatureNotSupported, + `Unsupported purchase verification provider: ${options.provider}.`, + ); + } + + const iapkit = options.iapkit; + const payloadCount = + Number(Boolean(iapkit?.amazon)) + + Number(Boolean(iapkit?.apple)) + + Number(Boolean(iapkit?.google)); + const amazon = iapkit?.amazon; + if (payloadCount !== 1 || !amazon) { + throw createVegaError( + ErrorCode.DeveloperError, + 'Amazon Vega IAPKit verification requires exactly one amazon payload.', + ); + } + + const receiptId = + typeof amazon.receiptId === 'string' ? amazon.receiptId.trim() : ''; + if (!receiptId) { + throw createVegaError( + ErrorCode.DeveloperError, + 'Amazon Vega IAPKit verification requires amazon.receiptId.', + ); + } + + let userId = typeof amazon.userId === 'string' ? amazon.userId.trim() : ''; + if (!userId) { + await getUserData(); + userId = cachedUserData?.userId?.trim() ?? ''; + } + if (!userId) { + throw createVegaError( + ErrorCode.DeveloperError, + 'Amazon Vega IAPKit verification could not resolve userId.', + ); + } + + const apiKey = + typeof iapkit?.apiKey === 'string' ? iapkit.apiKey.trim() : ''; + let response: Response; + try { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + IAPKIT_VERIFY_TIMEOUT_MS, + ); + response = await fetch(iapkitVerifyUrl(iapkit), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? {Authorization: `Bearer ${apiKey}`} : {}), + }, + body: JSON.stringify({ + store: 'amazon', + userId, + receiptId, + ...(amazon.sandbox == null ? {} : {sandbox: amazon.sandbox}), + }), + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + } catch (error) { + throw createVegaError( + ErrorCode.NetworkError, + error instanceof Error + ? error.message + : 'Failed to reach IAPKit verification endpoint.', + ); + } + let text: string; + try { + text = await response.text(); + } catch (error) { + throw createVegaError( + ErrorCode.NetworkError, + error instanceof Error + ? error.message + : 'Failed to read IAPKit verification response.', + ); + } + const json = parseIapkitJsonResponse(text); + + if (!response.ok) { + throw createVegaError( + ErrorCode.ReceiptFailed, + extractIapkitErrorMessage(json) ?? `HTTP ${response.status}`, + ); + } + + if (json === null) { + throw createVegaError( + ErrorCode.ReceiptFailed, + `IAPKit returned non-JSON response (HTTP ${response.status}).`, + ); + } + + if (!isIapkitResultObject(json)) { + throw createVegaError( + ErrorCode.ReceiptFailed, + `IAPKit returned malformed response (HTTP ${response.status}).`, + ); + } + + if (hasIapkitErrors(json)) { + throw createVegaError( + ErrorCode.ReceiptFailed, + extractIapkitErrorMessage(json) ?? 'IAPKit verification failed.', + ); + } + + const result = readIapkitResult(json, response.status); + return { + provider: 'iapkit', + iapkit: { + isValid: result.isValid, + state: result.state, + store: 'amazon', + }, + }; + }; + + const vegaModule: ExpoIapVegaModule = { + ERROR_CODES: ErrorCode, + async initConnection(): Promise { + return true; + }, + async endConnection(): Promise { + productTypesBySku.clear(); + subscriptionBasesBySku.clear(); + subscriptionParentsBySku.clear(); + cachedUserData = null; + return true; + }, + async fetchProducts( + type: string, + skus: string[], + ): Promise<(Product | ProductSubscription)[]> { + if (!Array.isArray(skus) || skus.length === 0) { + throw createVegaError(ErrorCode.EmptySkuList, 'No SKUs provided'); + } + + const products = await getProductData( + skus, + 'Failed to fetch Amazon Vega products', + ); + + return products + .filter((product) => { + cacheProductMetadata(product); + const openIapType = productTypeToOpenIap(getProductType(product)); + if (type === 'all') return true; + if (type === 'subs') return openIapType === 'subs'; + return openIapType === 'in-app'; + }) + .map(mapProduct); + }, + async requestPurchase(params): Promise { + let sku: string | undefined; + try { + const skus = params.skuArr ?? []; + if (skus.length !== 1) { + throw createVegaError( + ErrorCode.DeveloperError, + 'Amazon Vega purchase expects exactly one SKU per request.', + ); + } + + sku = skus[0]!; + const fallbackProductType = + params.type === 'subs' + ? PRODUCT_TYPE_SUBSCRIPTION + : productTypesBySku.get(sku); + if (fallbackProductType != null) { + productTypesBySku.set(sku, fallbackProductType); + } + let response: VegaPurchaseResponse; + const purchaseStartedAtMs = + Date.now() - PURCHASE_RECOVERY_CLOCK_SKEW_MS; + try { + response = await service.purchase({sku}); + } catch (error) { + if (isVegaParserError(error)) { + try { + const recovered = await recoverFulfillablePurchases( + sku, + fallbackProductType, + {minPurchaseDateMs: purchaseStartedAtMs}, + ); + if (recovered.requestedPurchases.length > 0) { + return recovered.requestedPurchases; + } + } catch { + // Keep the original parser error as the source of truth. + } + } + throw error; + } + + if ( + !isSuccess('purchase', response.responseCode) && + shouldRecoverPurchaseResponse(response.responseCode) + ) { + try { + const recovered = await recoverFulfillablePurchases( + sku, + fallbackProductType, + ); + if (recovered.requestedPurchases.length > 0) { + return recovered.requestedPurchases; + } + } catch { + // Keep the original purchase response as the source of truth. + } + } + + ensureSuccessful( + 'purchase', + response, + 'Failed to complete Amazon Vega purchase', + sku, + ); + cachedUserData = response.userData ?? cachedUserData; + + if (!response.receipt) return []; + const purchase = mapReceipt(response.receipt, fallbackProductType, sku); + emit('purchase-updated', purchase); + return [purchase]; + } catch (error) { + emit( + 'purchase-error', + toPurchaseErrorPayload( + error, + 'Failed to complete Amazon Vega purchase', + sku, + ), + ); + throw error; + } + }, + getAvailableItems, + async getActiveSubscriptions( + subscriptionIds?: string[] | null, + ): Promise { + const requestedIds = new Set(subscriptionIds ?? []); + const purchases = await getAvailableItems({ + includeSuspendedAndroid: false, + }); + return purchases + .filter( + (purchase) => + purchase.isAutoRenewing && + (requestedIds.size === 0 || requestedIds.has(purchase.productId)), + ) + .map((purchase) => ({ + productId: purchase.productId, + isActive: true, + transactionId: purchase.transactionId ?? purchase.id, + purchaseToken: purchase.purchaseToken ?? null, + transactionDate: purchase.transactionDate, + expirationDateIOS: null, + environmentIOS: null, + willExpireSoon: null, + daysUntilExpirationIOS: null, + renewalInfoIOS: null, + autoRenewingAndroid: + purchase.platform === 'android' + ? (( + purchase as Purchase & { + autoRenewingAndroid?: boolean | null; + } + ).autoRenewingAndroid ?? null) + : null, + basePlanIdAndroid: purchase.productId, + currentPlanId: purchase.productId, + purchaseTokenAndroid: purchase.purchaseToken ?? null, + })); + }, + async hasActiveSubscriptions( + subscriptionIds?: string[] | null, + ): Promise { + const subscriptions = + await vegaModule.getActiveSubscriptions(subscriptionIds); + return subscriptions.length > 0; + }, + async acknowledgePurchaseAndroid(purchaseToken): Promise { + await finishReceipt(purchaseToken); + }, + async consumePurchaseAndroid(purchaseToken): Promise { + await finishReceipt(purchaseToken); + }, + async getStorefront(): Promise { + return getStorefront(); + }, + async verifyPurchaseWithProvider(options) { + return verifyWithIapkit(options); + }, + addListener(eventName, listener) { + const listeners = listenersByEventName.get(eventName) ?? new Set(); + listeners.add(listener); + listenersByEventName.set(eventName, listeners); + return { + remove: () => { + listeners.delete(listener); + }, + }; + }, + removeListener(eventName, listener): void { + listenersByEventName.get(eventName)?.delete(listener); + }, + }; + + return vegaModule; +} diff --git a/libraries/expo-iap/src/vega.kepler.ts b/libraries/expo-iap/src/vega.kepler.ts new file mode 100644 index 00000000..c714711b --- /dev/null +++ b/libraries/expo-iap/src/vega.kepler.ts @@ -0,0 +1,30 @@ +import {Platform} from 'react-native'; +// eslint-disable-next-line import/no-unresolved +import {PurchasingService} from '@amazon-devices/keplerscript-appstore-iap-lib'; +import { + createExpoIapVegaModule, + type ExpoIapVegaModule, + type VegaPurchasingService, +} from './vega-adapter'; + +let cachedVegaModule: ExpoIapVegaModule | null = null; + +/** + * Returns true when the current React Native platform is Amazon Vega/Kepler. + */ +export const isVegaOS = (): boolean => { + return String(Platform.OS).toLowerCase() === 'kepler'; +}; + +/** + * Lazily creates the Vega IAP adapter backed by Amazon's Kepler Appstore IAP service. + */ +export const getVegaIapModule = (): ExpoIapVegaModule | null => { + if (!isVegaOS()) return null; + if (!cachedVegaModule) { + cachedVegaModule = createExpoIapVegaModule( + PurchasingService as unknown as VegaPurchasingService, + ); + } + return cachedVegaModule; +}; diff --git a/libraries/expo-iap/src/vega.ts b/libraries/expo-iap/src/vega.ts new file mode 100644 index 00000000..90c26c64 --- /dev/null +++ b/libraries/expo-iap/src/vega.ts @@ -0,0 +1,10 @@ +import {Platform} from 'react-native'; +import type {ExpoIapVegaModule} from './vega-adapter'; + +export const isVegaOS = (): boolean => { + return String(Platform.OS).toLowerCase() === 'kepler'; +}; + +export const getVegaIapModule = (): ExpoIapVegaModule | null => { + return null; +}; diff --git a/libraries/flutter_inapp_purchase/android/build.gradle b/libraries/flutter_inapp_purchase/android/build.gradle index 9e853e87..c6c5bb82 100644 --- a/libraries/flutter_inapp_purchase/android/build.gradle +++ b/libraries/flutter_inapp_purchase/android/build.gradle @@ -134,17 +134,21 @@ android { } compileSdk = openIapCompileSdkVersion - // Read horizonEnabled from gradle.properties, default to false (play) 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 openiapFlavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') defaultConfig { minSdkVersion = openIapMinSdkVersion targetSdkVersion = openIapTargetSdkVersion testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - // Select the local :openiap flavor when developing inside the monorepo. - def flavor = horizonEnabled ? 'horizon' : 'play' - missingDimensionStrategy "platform", flavor + // Select the matching openiap-google flavor. + missingDimensionStrategy "platform", openiapFlavor + buildConfigField "String", "OPENIAP_STORE", "\"${openiapFlavor}\"" } sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -157,6 +161,9 @@ android { targetCompatibility JavaVersion.VERSION_17 } + buildFeatures { + buildConfig true + } } kotlin { @@ -168,9 +175,12 @@ kotlin { dependencies { // In monorepo: use local packages/google source if available def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false + def fireOsEnabled = project.findProperty('fireOsEnabled')?.toBoolean() ?: false def localGoogleProject = findProject(':openiap') if (localGoogleProject != null) { implementation project(':openiap') + } else if (fireOsEnabled) { + implementation "io.github.hyochan.openiap:openiap-google-amazon:${openiapGoogleVersion}" } else if (horizonEnabled) { implementation "io.github.hyochan.openiap:openiap-google-horizon:${openiapGoogleVersion}" } else { @@ -178,6 +188,7 @@ dependencies { } implementation "androidx.annotation:annotation:${readRequiredAndroidGradleProperty(projectDir, 'openIapAndroidAnnotationVersion')}" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' // Google Play Billing comes transitively from openiap-google so its // version stays centralized in packages/google. diff --git a/libraries/flutter_inapp_purchase/android/settings.gradle b/libraries/flutter_inapp_purchase/android/settings.gradle index 961a10e4..308db5a9 100644 --- a/libraries/flutter_inapp_purchase/android/settings.gradle +++ b/libraries/flutter_inapp_purchase/android/settings.gradle @@ -1,7 +1,22 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "com.android.library" version "8.7.3" apply false + id "org.jetbrains.kotlin.android" version "2.2.0" apply false + id "org.jetbrains.kotlin.plugin.compose" version "2.2.0" apply false + id "com.vanniktech.maven.publish" version "0.34.0" apply false +} + rootProject.name = 'flutter_inapp_purchase' -// Optional: include the monorepo openiap-google module for debugging. -// android/build.gradle automatically uses project(":openiap") when it exists. -// -// include ':openiap' -// project(':openiap').projectDir = new File(settingsDir, '../../../packages/google/openiap') +def localOpenIapProject = new File(settingsDir, '../../../packages/google/openiap') +if (localOpenIapProject.exists()) { + include ':openiap' + project(':openiap').projectDir = localOpenIapProject +} diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index 1c587ab4..7d916b9a 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -15,6 +15,7 @@ import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid import dev.hyo.openiap.ExternalLinkLaunchModeAndroid import dev.hyo.openiap.ExternalLinkTypeAndroid import dev.hyo.openiap.FetchProductsResult +import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions import dev.hyo.openiap.InitConnectionConfig @@ -87,11 +88,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act deduplicate: Boolean = false ): JSONArray { val entries: List> = when (result) { + is FetchProductsResultAll -> result.value?.map { it.toJson() } + ?: emptyList() is FetchProductsResultProducts -> result.value?.map { it.toJson() } ?: emptyList() is FetchProductsResultSubscriptions -> result.value?.map { it.toJson() } ?: emptyList() - else -> emptyList>() } val array = JSONArray() val seenIds = mutableSetOf() @@ -247,6 +249,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } + @Suppress("DEPRECATION") override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { val ch = channel if (ch == null) { @@ -1192,6 +1195,20 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act ((iapkit["apple"] as? Map<*, *>)?.get("jws") as? String)?.let { jws -> iapkitMap["apple"] = mapOf("jws" to jws) } + (iapkit["amazon"] as? Map<*, *>)?.let { amazon -> + (amazon["receiptId"] as? String)?.let { receiptId -> + val amazonMap = mutableMapOf( + "receiptId" to receiptId + ) + (amazon["sandbox"] as? Boolean)?.let { sandbox -> + amazonMap["sandbox"] = sandbox + } + (amazon["userId"] as? String)?.let { userId -> + amazonMap["userId"] = userId + } + iapkitMap["amazon"] = amazonMap + } + } propsMap["iapkit"] = iapkitMap } diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt index 98cb0cfd..08458dfb 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt @@ -20,7 +20,8 @@ class FlutterInappPurchasePlugin : FlutterPlugin, ActivityAware { private fun onAttached(context: Context, messenger: BinaryMessenger) { val methodChannel = MethodChannel(messenger, "flutter_inapp") channel = methodChannel - logInfo("Initializing Android IAP plugin") + configuredStore = BuildConfig.OPENIAP_STORE + logInfo("Initializing Android IAP plugin for ${configuredStore} store") val plugin = AndroidInappPurchasePlugin() plugin.setContext(context) plugin.setChannel(methodChannel) @@ -54,6 +55,7 @@ class FlutterInappPurchasePlugin : FlutterPlugin, ActivityAware { companion object { private const val TAG = "FlutterInappPurchase" + private var configuredStore = "play" private fun logInfo(message: String) { if (Log.isLoggable(TAG, Log.INFO)) { @@ -62,7 +64,11 @@ class FlutterInappPurchasePlugin : FlutterPlugin, ActivityAware { } fun getStore(): String { - return "play_store" + return when (configuredStore) { + "amazon" -> "amazon" + "horizon" -> "horizon" + else -> "play_store" + } } } } diff --git a/libraries/flutter_inapp_purchase/example/README.md b/libraries/flutter_inapp_purchase/example/README.md index 4bf93883..223ae992 100644 --- a/libraries/flutter_inapp_purchase/example/README.md +++ b/libraries/flutter_inapp_purchase/example/README.md @@ -18,6 +18,7 @@ This example supports multiple billing platforms: - **Google Play Billing** (default) - **Meta Horizon Billing** (for Meta Quest devices) +- **Fire OS IAP** (Amazon Appstore distribution) ### Google Play (Default) @@ -33,11 +34,13 @@ flutter build apk --release To use Meta Horizon billing: 1. **Enable Horizon** in `android/gradle.properties`: + ```properties horizonEnabled=true ``` 2. **Add Horizon App ID** to `android/local.properties`: + ```properties HORIZON_APP_ID=your_horizon_app_id_here ``` @@ -48,13 +51,39 @@ To use Meta Horizon billing: flutter build apk --release ``` -**No flavor specification needed!** The build system automatically selects the correct billing platform based on `horizonEnabled`. +**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. **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: + + ```bash + flutter run + flutter build apk --release + ``` + +The build system automatically selects the Fire OS `amazon` flavor based on +`fireOsEnabled`. ## IDE Configuration ### Android Studio -Just click **Run** - the build system automatically selects the right platform based on `horizonEnabled` in `gradle.properties`. +Just click **Run** - the build system automatically selects the right platform based on `horizonEnabled` or `fireOsEnabled` in `gradle.properties`. ### VS Code @@ -64,3 +93,4 @@ Press F5 or click **Start Debugging** - works out of the box! - **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 diff --git a/libraries/flutter_inapp_purchase/example/android/app/build.gradle b/libraries/flutter_inapp_purchase/example/android/app/build.gradle index 3e3c4168..d67b81b0 100644 --- a/libraries/flutter_inapp_purchase/example/android/app/build.gradle +++ b/libraries/flutter_inapp_purchase/example/android/app/build.gradle @@ -56,9 +56,13 @@ android { versionName = flutterVersionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - // Read horizonEnabled flag from gradle.properties (default: false) + // Read store flags from gradle.properties (default: Play) def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false - def flavor = horizonEnabled ? 'horizon' : 'play' + 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 flavor = fireOsEnabled ? 'amazon' : (horizonEnabled ? 'horizon' : 'play') // Select platform flavor from plugin's product flavors missingDimensionStrategy 'platform', flavor diff --git a/libraries/flutter_inapp_purchase/example/android/gradle.properties b/libraries/flutter_inapp_purchase/example/android/gradle.properties index 5a000b5b..8a4f22c3 100644 --- a/libraries/flutter_inapp_purchase/example/android/gradle.properties +++ b/libraries/flutter_inapp_purchase/example/android/gradle.properties @@ -11,3 +11,12 @@ openIapEspressoCoreVersion=3.5.1 # 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 +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/libraries/flutter_inapp_purchase/example/android/settings.gradle b/libraries/flutter_inapp_purchase/example/android/settings.gradle index 543fd7b0..c329a22a 100644 --- a/libraries/flutter_inapp_purchase/example/android/settings.gradle +++ b/libraries/flutter_inapp_purchase/example/android/settings.gradle @@ -34,9 +34,15 @@ pluginManagement { def androidGradlePluginVersion = googlePluginVersion('com.android.application') ?: readGradleProperty('openIapAndroidGradlePluginVersion') ?: '8.13.2' + def androidLibraryPluginVersion = googlePluginVersion('com.android.library') + ?: androidGradlePluginVersion def kotlinPluginVersion = googlePluginVersion('org.jetbrains.kotlin.android') ?: readGradleProperty('openIapKotlinVersion') ?: '2.2.0' + def composePluginVersion = googlePluginVersion('org.jetbrains.kotlin.plugin.compose') + ?: kotlinPluginVersion + def vanniktechPluginVersion = googlePluginVersion('com.vanniktech.maven.publish') + ?: '0.35.0' def flutterSdkPath = { def properties = new Properties() @@ -59,9 +65,18 @@ pluginManagement { if (requested.id.id == "com.android.application") { useVersion(androidGradlePluginVersion) } + if (requested.id.id == "com.android.library") { + useVersion(androidLibraryPluginVersion) + } if (requested.id.id == "org.jetbrains.kotlin.android") { useVersion(kotlinPluginVersion) } + if (requested.id.id == "org.jetbrains.kotlin.plugin.compose") { + useVersion(composePluginVersion) + } + if (requested.id.id == "com.vanniktech.maven.publish") { + useVersion(vanniktechPluginVersion) + } } } } @@ -69,7 +84,16 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" apply false + id "com.android.library" apply false id "org.jetbrains.kotlin.android" apply false + id "org.jetbrains.kotlin.plugin.compose" apply false + id "com.vanniktech.maven.publish" apply false } include ':app' + +def localOpenIapProject = new File(settingsDir, '../../../../packages/google/openiap') +if (localOpenIapProject.exists()) { + include ':openiap' + project(':openiap').projectDir = localOpenIapProject +} diff --git a/libraries/flutter_inapp_purchase/example/env.example b/libraries/flutter_inapp_purchase/example/env.example index a52704fc..ae9c7bb0 100644 --- a/libraries/flutter_inapp_purchase/example/env.example +++ b/libraries/flutter_inapp_purchase/example/env.example @@ -1,3 +1,4 @@ # IAPKit API Key for purchase verification # Get your API key from https://kit.openiap.dev IAPKIT_API_KEY=your_iapkit_api_key_here +IAPKIT_BASE_URL=https://kit.openiap.dev diff --git a/libraries/flutter_inapp_purchase/example/lib/main.dart b/libraries/flutter_inapp_purchase/example/lib/main.dart index 5f031e09..24d66d03 100644 --- a/libraries/flutter_inapp_purchase/example/lib/main.dart +++ b/libraries/flutter_inapp_purchase/example/lib/main.dart @@ -4,6 +4,6 @@ import 'src/app.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: 'env'); + await dotenv.load(fileName: 'env.example'); runApp(const App()); } diff --git a/libraries/flutter_inapp_purchase/example/lib/src/constants.dart b/libraries/flutter_inapp_purchase/example/lib/src/constants.dart index 073e3636..3439f71d 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/constants.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/constants.dart @@ -4,9 +4,17 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; class IapConstants { // IAPKit API Key for purchase verification // Get your API key from https://kit.openiap.dev - static String get iapkitApiKey => dotenv.env['IAPKIT_API_KEY'] ?? ''; - static String get iapkitBaseUrl => - dotenv.env['IAPKIT_BASE_URL'] ?? 'https://kit.openiap.dev'; + static const _iapkitApiKeyFromEnvironment = + String.fromEnvironment('IAPKIT_API_KEY'); + static const _iapkitBaseUrlFromEnvironment = + String.fromEnvironment('IAPKIT_BASE_URL'); + + static String get iapkitApiKey => _iapkitApiKeyFromEnvironment.isNotEmpty + ? _iapkitApiKeyFromEnvironment + : dotenv.env['IAPKIT_API_KEY'] ?? ''; + static String get iapkitBaseUrl => _iapkitBaseUrlFromEnvironment.isNotEmpty + ? _iapkitBaseUrlFromEnvironment + : dotenv.env['IAPKIT_BASE_URL'] ?? 'https://kit.openiap.dev'; // Consumable Product IDs static const List consumableProductIds = [ 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 54a70e45..c299f705 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 @@ -242,8 +242,7 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt } debugPrint('✅ Purchase detected as successful: ${purchase.productId}'); - debugPrint( - 'Purchase token: ${_formatTokenValue(purchase.purchaseToken)}'); + debugPrint('Purchase token: ${_formatTokenValue(purchase.purchaseToken)}'); debugPrint('ID: ${purchase.id}'); // OpenIAP standard debugPrint('Transaction ID: ${transactionId ?? 'N/A'}'); 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 384670f7..b35bf03e 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 @@ -810,7 +810,8 @@ Store: ${iapkitResult.store.value} // Use current subscription token if available, otherwise use a test token final testToken = _currentActiveSubscription?.purchaseToken ?? 'test_empty_token_${DateTime.now().millisecondsSinceEpoch}'; - debugPrint('Using test token: ${testToken.isEmpty ? 'empty' : testToken}'); + debugPrint( + 'Using test token: ${testToken.isEmpty ? 'empty' : testToken}'); // Test with empty string - but pass validation by using a non-empty token final requestProps = RequestPurchaseProps.subs(( diff --git a/libraries/flutter_inapp_purchase/example/pubspec.yaml b/libraries/flutter_inapp_purchase/example/pubspec.yaml index af03b9c8..645043f5 100644 --- a/libraries/flutter_inapp_purchase/example/pubspec.yaml +++ b/libraries/flutter_inapp_purchase/example/pubspec.yaml @@ -42,7 +42,7 @@ flutter: uses-material-design: true assets: - - env + - env.example # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.io/assets-and-images/#resolution-aware. diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index aa23951a..b97bf179 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -934,6 +934,30 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { if let purchaseToken = (iapkit["google"] as? [String: Any])?["purchaseToken"] as? String { iapkitDict["google"] = ["purchaseToken": purchaseToken] } + if let amazon = iapkit["amazon"] as? [String: Any] { + guard let rawReceiptId = amazon["receiptId"] as? String else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "iapkit.amazon.receiptId required", details: nil)) + return + } + let receiptId = rawReceiptId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !receiptId.isEmpty else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "iapkit.amazon.receiptId required", details: nil)) + return + } + var amazonDict: [String: Any] = ["receiptId": receiptId] + if let sandbox = amazon["sandbox"] as? Bool { + amazonDict["sandbox"] = sandbox + } + if let userId = amazon["userId"] as? String { + let trimmedUserId = userId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedUserId.isEmpty { + amazonDict["userId"] = trimmedUserId + } + } + iapkitDict["amazon"] = amazonDict + } propsDict["iapkit"] = iapkitDict } diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index f6096b0f..9b05e4be 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1919,6 +1919,14 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { if (iapkit.apple != null) 'apple': {'jws': iapkit.apple!.jws}, if (iapkit.google != null) 'google': {'purchaseToken': iapkit.google!.purchaseToken}, + if (iapkit.amazon != null) + 'amazon': { + 'receiptId': iapkit.amazon!.receiptId, + if (iapkit.amazon!.sandbox != null) + 'sandbox': iapkit.amazon!.sandbox, + if (iapkit.amazon!.userId != null) + 'userId': iapkit.amazon!.userId, + }, }; } @@ -1951,22 +1959,51 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } // Parse iapkit result (single object, not array) + gentype.RequestVerifyPurchaseWithIapkitResult parseIapkitResult( + dynamic value) { + if (value is! Map) { + throw PurchaseError( + code: gentype.ErrorCode.PurchaseVerificationFailed, + message: + 'Malformed IAPKit verification result: iapkit must be an object', + ); + } + + final itemMap = value.map( + (key, value) => MapEntry(key.toString(), value), + ); + final isValid = itemMap['isValid']; + final state = itemMap['state']; + final store = itemMap['store']; + if (isValid is! bool || state == null || store == null) { + throw PurchaseError( + code: gentype.ErrorCode.PurchaseVerificationFailed, + message: + 'Malformed IAPKit verification result: missing isValid, state, or store', + ); + } + + return gentype.RequestVerifyPurchaseWithIapkitResult( + isValid: isValid, + state: gentype.IapkitPurchaseState.fromJson( + state.toString(), + ), + store: gentype.IapStore.fromJson(store.toString()), + ); + } + gentype.RequestVerifyPurchaseWithIapkitResult? iapkitResult; final iapkitData = resultMap['iapkit']; if (iapkitData != null) { - final itemMap = iapkitData is Map - ? iapkitData.map( - (k, v) => MapEntry(k.toString(), v), - ) - : {}; - iapkitResult = gentype.RequestVerifyPurchaseWithIapkitResult( - isValid: itemMap['isValid'] as bool? ?? false, - state: gentype.IapkitPurchaseState.fromJson( - itemMap['state']?.toString() ?? 'unknown', - ), - store: gentype.IapStore.fromJson( - itemMap['store']?.toString() ?? 'apple', - ), + iapkitResult = parseIapkitResult(iapkitData); + } + + final providerValue = resultMap['provider']; + if (providerValue == null) { + throw PurchaseError( + code: gentype.ErrorCode.PurchaseVerificationFailed, + message: + 'Malformed IAPKit verification result: missing provider', ); } @@ -1987,7 +2024,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { iapkit: iapkitResult, errors: errors, provider: gentype.PurchaseVerificationProvider.fromJson( - resultMap['provider']?.toString() ?? 'iapkit', + providerValue.toString(), ), ); } on PlatformException catch (error) { diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 9723c028..1f93a65f 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -534,7 +534,8 @@ enum IapStore { Unknown('unknown'), Apple('apple'), Google('google'), - Horizon('horizon'); + Horizon('horizon'), + Amazon('amazon'); const IapStore(this.value); final String value; @@ -550,6 +551,8 @@ enum IapStore { return IapStore.Google; case 'horizon': return IapStore.Horizon; + case 'amazon': + return IapStore.Amazon; } throw ArgumentError('Unknown IapStore value: $value'); } @@ -4540,7 +4543,8 @@ class _SubsPurchase extends RequestPurchaseProps { /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) class RequestPurchasePropsByPlatforms { const RequestPurchasePropsByPlatforms({ @@ -4717,7 +4721,8 @@ class RequestSubscriptionIosProps { /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms { const RequestSubscriptionPropsByPlatforms({ @@ -4755,6 +4760,37 @@ class RequestSubscriptionPropsByPlatforms { } } +class RequestVerifyPurchaseWithIapkitAmazonProps { + const RequestVerifyPurchaseWithIapkitAmazonProps({ + required this.receiptId, + this.sandbox, + this.userId, + }); + + /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + final String receiptId; + /// Use Amazon RVS Cloud Sandbox for App Tester receipts. + final bool? sandbox; + /// Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + final String? userId; + + factory RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(Map json) { + return RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId: json['receiptId'] as String, + sandbox: json['sandbox'] as bool?, + userId: json['userId'] as String?, + ); + } + + Map toJson() { + return { + 'receiptId': receiptId, + 'sandbox': sandbox, + 'userId': userId, + }; + } +} + class RequestVerifyPurchaseWithIapkitAppleProps { const RequestVerifyPurchaseWithIapkitAppleProps({ required this.jws, @@ -4801,13 +4837,17 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) class RequestVerifyPurchaseWithIapkitProps { const RequestVerifyPurchaseWithIapkitProps({ + this.amazon, this.apiKey, this.apple, this.google, }); + /// Amazon Appstore verification parameters. + final RequestVerifyPurchaseWithIapkitAmazonProps? amazon; /// API key used for the Authorization header (Bearer {apiKey}). final String? apiKey; /// Apple App Store verification parameters. @@ -4817,6 +4857,7 @@ class RequestVerifyPurchaseWithIapkitProps { factory RequestVerifyPurchaseWithIapkitProps.fromJson(Map json) { return RequestVerifyPurchaseWithIapkitProps( + amazon: json['amazon'] != null ? RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(json['amazon'] as Map) : null, apiKey: json['apiKey'] as String?, apple: json['apple'] != null ? RequestVerifyPurchaseWithIapkitAppleProps.fromJson(json['apple'] as Map) : null, google: json['google'] != null ? RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(json['google'] as Map) : null, @@ -4825,6 +4866,7 @@ class RequestVerifyPurchaseWithIapkitProps { Map toJson() { return { + 'amazon': amazon?.toJson(), 'apiKey': apiKey, 'apple': apple?.toJson(), 'google': google?.toJson(), diff --git a/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift index cf1318c5..b907d7ea 100644 --- a/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift @@ -799,6 +799,30 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { if let purchaseToken = (iapkit["google"] as? [String: Any])?["purchaseToken"] as? String { iapkitDict["google"] = ["purchaseToken": purchaseToken] } + if let amazon = iapkit["amazon"] as? [String: Any] { + guard let rawReceiptId = amazon["receiptId"] as? String else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "iapkit.amazon.receiptId required", details: nil)) + return + } + let receiptId = rawReceiptId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !receiptId.isEmpty else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "iapkit.amazon.receiptId required", details: nil)) + return + } + var amazonDict: [String: Any] = ["receiptId": receiptId] + if let sandbox = amazon["sandbox"] as? Bool { + amazonDict["sandbox"] = sandbox + } + if let userId = amazon["userId"] as? String { + let trimmedUserId = userId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedUserId.isEmpty { + amazonDict["userId"] = trimmedUserId + } + } + iapkitDict["amazon"] = amazonDict + } propsDict["iapkit"] = iapkitDict } diff --git a/libraries/flutter_inapp_purchase/pubspec.yaml b/libraries/flutter_inapp_purchase/pubspec.yaml index cc1f7ee3..c62cecd1 100644 --- a/libraries/flutter_inapp_purchase/pubspec.yaml +++ b/libraries/flutter_inapp_purchase/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_inapp_purchase description: In App Purchase plugin for flutter. This project has been forked by react-native-iap and we are willing to share same experience with that on react-native. -version: 9.3.2 +version: 9.4.0-rc.1 homepage: https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase repository: https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase environment: diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index ed5557f9..c2812ed2 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -2188,6 +2188,70 @@ void main() { expect(result.iapkit!.store, types.IapStore.Google); }); + test('sends correct payload for Amazon verification', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + switch (call.method) { + case 'initConnection': + return true; + case 'verifyPurchaseWithProvider': + return jsonEncode({ + 'provider': 'iapkit', + 'iapkit': { + 'isValid': true, + 'state': 'entitled', + 'store': 'amazon', + }, + }); + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + await iap.initConnection(); + + final result = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + apiKey: 'test-api-key', + amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId: 'amzn1.receipt.test', + sandbox: true, + userId: 'amzn1.account.test', + ), + ), + ); + + final verifyCall = calls.singleWhere( + (MethodCall c) => c.method == 'verifyPurchaseWithProvider', + ); + final payload = Map.from( + verifyCall.arguments as Map, + ); + + expect(payload['provider'], 'iapkit'); + final iapkitPayload = Map.from( + payload['iapkit'] as Map, + ); + expect(iapkitPayload['amazon'], isNotNull); + final amazonPayload = Map.from( + iapkitPayload['amazon'] as Map, + ); + expect(amazonPayload['receiptId'], 'amzn1.receipt.test'); + expect(amazonPayload['sandbox'], true); + expect(amazonPayload['userId'], 'amzn1.account.test'); + + expect(result.iapkit, isNotNull); + expect(result.iapkit!.isValid, true); + expect(result.iapkit!.state, types.IapkitPurchaseState.Entitled); + expect(result.iapkit!.store, types.IapStore.Amazon); + }); + test('throws PurchaseError on platform exception', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { @@ -2264,6 +2328,50 @@ void main() { expect(result.iapkit, isNull); }); + test('rejects missing provider in response', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'verifyPurchaseWithProvider': + return { + 'iapkit': { + 'isValid': true, + 'state': 'entitled', + 'store': 'apple', + }, + }; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await iap.initConnection(); + + await expectLater( + iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + apiKey: 'test-api-key', + apple: types.RequestVerifyPurchaseWithIapkitAppleProps( + jws: 'test-jws-token', + ), + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + types.ErrorCode.PurchaseVerificationFailed, + ), + ), + ); + }); + test('handles errors in response', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { @@ -2317,7 +2425,7 @@ void main() { expect(result.errors![1].message, 'Subscription has expired'); }); - test('handles iapkit as non-Map gracefully', () async { + test('rejects iapkit as non-Map', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { @@ -2338,20 +2446,24 @@ void main() { await iap.initConnection(); - final result = await iap.verifyPurchaseWithProvider( - provider: types.PurchaseVerificationProvider.Iapkit, - iapkit: const types.RequestVerifyPurchaseWithIapkitProps( - apiKey: 'test-api-key', - apple: types.RequestVerifyPurchaseWithIapkitAppleProps( - jws: 'test-jws-token', + await expectLater( + iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + apiKey: 'test-api-key', + apple: types.RequestVerifyPurchaseWithIapkitAppleProps( + jws: 'test-jws-token', + ), + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + types.ErrorCode.PurchaseVerificationFailed, ), ), ); - - // Should handle gracefully with default values - expect(result.provider, types.PurchaseVerificationProvider.Iapkit); - expect(result.iapkit, isNotNull); - expect(result.iapkit!.isValid, false); // Default value }); }); } diff --git a/libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap b/libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap index c25cd3ad..fa6b1b0c 100644 --- a/libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap +++ b/libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap @@ -5,4 +5,4 @@ binary="GodotIap.release.aar" [dependencies] local=[] -remote=["io.github.hyochan.openiap:openiap-google:2.2.1", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"] +remote=["io.github.hyochan.openiap:openiap-google:2.3.0-rc.1", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"] diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index 32baa822..72be4130 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -667,17 +667,30 @@ func get_storefront() -> String: ## See: https://openiap.dev/docs/features/validation#verify-purchase func verify_purchase(props) -> Variant: print("[GodotIap] verify_purchase called") - var result = _verify_purchase_raw(props.to_dict()) + var props_dict: Dictionary = props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {}) + if _native_plugin and _platform == "iOS": + var pending = _native_plugin.call("verifyPurchase", JSON.stringify(props_dict)) + var request_id = _parse_request_id(pending) + if request_id.is_empty(): + push_warning("[GodotIap] verify_purchase missing requestId") + return null + var payload = await _await_products_fetched_for("verifyPurchase", request_id) + if payload is Dictionary and payload.get("success", false): + var payload_json = payload.get("resultJson", "") + var decoded = JSON.parse_string(payload_json) + if decoded is Dictionary: + return Types.VerifyPurchaseResultIOS.from_dict(decoded) + return null + + var result = _verify_purchase_raw(props_dict) if result.get("success", false) or result.get("isValid", false): - if _platform == "iOS": - return Types.VerifyPurchaseResultIOS.from_dict(result) - elif _platform == "Android": + if _platform == "Android": return Types.VerifyPurchaseResultAndroid.from_dict(result) return null ## Internal: Verify purchase with raw Dictionary func _verify_purchase_raw(props: Dictionary) -> Dictionary: - if _native_plugin and (_platform == "Android" or _platform == "iOS"): + if _native_plugin and _platform == "Android": var props_json = JSON.stringify(props) var result_json = _native_plugin.call("verifyPurchase", props_json) var result = JSON.parse_string(result_json) @@ -693,19 +706,58 @@ func _verify_purchase_raw(props: Dictionary) -> Dictionary: ## See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider func verify_purchase_with_provider(props) -> Variant: print("[GodotIap] verify_purchase_with_provider called") - var result = _verify_purchase_with_provider_raw(props.to_dict()) + var props_dict: Dictionary = props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {}) + if _native_plugin and _platform == "iOS": + var pending = _native_plugin.call("verifyPurchaseWithProvider", JSON.stringify(props_dict)) + var request_id = _parse_request_id(pending) + if request_id.is_empty(): + push_warning("[GodotIap] verify_purchase_with_provider missing requestId") + return Types.VerifyPurchaseWithProviderResult.from_dict({ + "provider": props_dict.get("provider", "iapkit"), + "errors": [ + { + "code": "purchase-verification-failed", + "message": "Missing requestId", + }, + ], + }) + var payload = await _await_products_fetched_for("verifyPurchaseWithProvider", request_id) + if payload is Dictionary and payload.get("success", false): + var payload_json = payload.get("resultJson", "") + var decoded = JSON.parse_string(payload_json) + if decoded is Dictionary: + return Types.VerifyPurchaseWithProviderResult.from_dict(decoded) + return Types.VerifyPurchaseWithProviderResult.from_dict({ + "provider": props_dict.get("provider", "iapkit"), + "errors": [ + { + "code": "purchase-verification-failed", + "message": payload.get("error", "Verification failed") if payload is Dictionary else "Verification failed", + }, + ], + }) + + var result = _verify_purchase_with_provider_raw(props_dict) return Types.VerifyPurchaseWithProviderResult.from_dict(result) ## Internal: Verify purchase with provider raw Dictionary func _verify_purchase_with_provider_raw(props: Dictionary) -> Dictionary: - if _native_plugin and (_platform == "Android" or _platform == "iOS"): + if _native_plugin and _platform == "Android": var props_json = JSON.stringify(props) var result_json = _native_plugin.call("verifyPurchaseWithProvider", props_json) var result = JSON.parse_string(result_json) if result is Dictionary: return result # No native plugin - return { "success": false, "isValid": false, "error": "Not available in no native plugin" } + return { + "provider": props.get("provider", "iapkit"), + "errors": [ + { + "code": "feature-not-supported", + "message": "Not available in no native plugin", + }, + ], + } # ========================================== diff --git a/libraries/godot-iap/addons/godot-iap/plugin.cfg b/libraries/godot-iap/addons/godot-iap/plugin.cfg index 164d8178..28b252f5 100644 --- a/libraries/godot-iap/addons/godot-iap/plugin.cfg +++ b/libraries/godot-iap/addons/godot-iap/plugin.cfg @@ -3,5 +3,5 @@ name="GodotIap" description="Cross-platform in-app purchase plugin for Godot using OpenIAP" author="hyochan" -version="2.3.1" +version="2.4.0-rc.1" script="godot_iap_plugin.gd" diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 76bc09e7..802cf309 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -182,6 +182,7 @@ enum IapStore { APPLE = 1, GOOGLE = 2, HORIZON = 3, + AMAZON = 4, } ## Payment mode for subscription offers. Determines how the user pays during the offer period. @@ -4029,7 +4030,7 @@ class RequestPurchaseProps: dict["useAlternativeBilling"] = use_alternative_billing return dict -## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, or Horizon when built with horizon flavor (determined at build time, not runtime) +## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestPurchasePropsByPlatforms: ## Apple-specific purchase parameters var apple: RequestPurchaseIosProps @@ -4254,7 +4255,7 @@ class RequestSubscriptionIosProps: dict["advancedCommerceData"] = advanced_commerce_data return dict -## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, or Horizon when built with horizon flavor (determined at build time, not runtime) +## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms: ## Apple-specific subscription parameters var apple: RequestSubscriptionIosProps @@ -4313,6 +4314,34 @@ class RequestSubscriptionPropsByPlatforms: dict["android"] = android return dict +class RequestVerifyPurchaseWithIapkitAmazonProps: + ## Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + var user_id: Variant = null + ## Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + var receipt_id: String = "" + ## Use Amazon RVS Cloud Sandbox for App Tester receipts. + var sandbox: Variant = null + + static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitAmazonProps: + var obj = RequestVerifyPurchaseWithIapkitAmazonProps.new() + if data.has("userId") and data["userId"] != null: + obj.user_id = data["userId"] + if data.has("receiptId") and data["receiptId"] != null: + obj.receipt_id = data["receiptId"] + if data.has("sandbox") and data["sandbox"] != null: + obj.sandbox = data["sandbox"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if user_id != null: + dict["userId"] = user_id + if receipt_id != null: + dict["receiptId"] = receipt_id + if sandbox != null: + dict["sandbox"] = sandbox + return dict + class RequestVerifyPurchaseWithIapkitAppleProps: ## The JWS token returned with the purchase response. var jws: String = "" @@ -4345,7 +4374,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: dict["purchaseToken"] = purchase_token return dict -## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) +## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null @@ -4353,6 +4382,8 @@ class RequestVerifyPurchaseWithIapkitProps: var apple: RequestVerifyPurchaseWithIapkitAppleProps ## Google Play Store verification parameters. var google: RequestVerifyPurchaseWithIapkitGoogleProps + ## Amazon Appstore verification parameters. + var amazon: RequestVerifyPurchaseWithIapkitAmazonProps static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitProps: var obj = RequestVerifyPurchaseWithIapkitProps.new() @@ -4368,6 +4399,11 @@ class RequestVerifyPurchaseWithIapkitProps: obj.google = RequestVerifyPurchaseWithIapkitGoogleProps.from_dict(data["google"]) else: obj.google = data["google"] + if data.has("amazon") and data["amazon"] != null: + if data["amazon"] is Dictionary: + obj.amazon = RequestVerifyPurchaseWithIapkitAmazonProps.from_dict(data["amazon"]) + else: + obj.amazon = data["amazon"] return obj func to_dict() -> Dictionary: @@ -4384,6 +4420,11 @@ class RequestVerifyPurchaseWithIapkitProps: dict["google"] = google.to_dict() else: dict["google"] = google + if amazon != null: + if amazon.has_method("to_dict"): + dict["amazon"] = amazon.to_dict() + else: + dict["amazon"] = amazon return dict ## Product-level subscription replacement parameters (Android) Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams Available in Google Play Billing Library 8.1.0+ @@ -4728,7 +4769,8 @@ const IAP_STORE_VALUES = { IapStore.UNKNOWN: "unknown", IapStore.APPLE: "apple", IapStore.GOOGLE: "google", - IapStore.HORIZON: "horizon" + IapStore.HORIZON: "horizon", + IapStore.AMAZON: "amazon" } const PAYMENT_MODE_VALUES = { @@ -4997,7 +5039,8 @@ const IAP_STORE_FROM_STRING = { "unknown": IapStore.UNKNOWN, "apple": IapStore.APPLE, "google": IapStore.GOOGLE, - "horizon": IapStore.HORIZON + "horizon": IapStore.HORIZON, + "amazon": IapStore.AMAZON } const PAYMENT_MODE_FROM_STRING = { diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt index ac8af7dd..b895f0da 100644 --- a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt @@ -922,68 +922,90 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { @UsedByGodot fun verifyPurchaseWithProvider(propsJson: String): String { - GodotIapLog.payload("verifyPurchaseWithProvider", propsJson) + GodotIapLog.payload( + "verifyPurchaseWithProvider", + mapOf("hasIapkit" to propsJson.contains("\"iapkit\"")) + ) - if (!isInitialized) { + fun errorResponse(message: String): String { return JSONObject().apply { put("success", false) - put("error", "Not initialized") + put("provider", PurchaseVerificationProvider.Iapkit.toJson()) + put( + "errors", + JSONArray().apply { + put(JSONObject().apply { + put("code", ErrorCode.PurchaseVerificationFailed.toJson()) + put("message", message) + }) + } + ) }.toString() } + if (!isInitialized) { + return errorResponse("Not initialized") + } + return runBlocking { try { - val json = JSONObject(propsJson) + val jsonBridge = object { + fun objectToMap(json: JSONObject): Map { + val map = linkedMapOf() + val keys = json.keys() + while (keys.hasNext()) { + val key = keys.next() + map[key] = valueToAny(json.opt(key)) + } + return map + } - // Build IAPKit props - val iapkitProps = RequestVerifyPurchaseWithIapkitProps( - apiKey = json.optString("apiKey").takeIf { it.isNotEmpty() }, - apple = json.optJSONObject("apple")?.let { appleJson -> - RequestVerifyPurchaseWithIapkitAppleProps( - jws = appleJson.getString("jws") - ) - }, - google = json.optJSONObject("google")?.let { googleJson -> - RequestVerifyPurchaseWithIapkitGoogleProps( - purchaseToken = googleJson.getString("purchaseToken") - ) + fun arrayToList(json: JSONArray): List { + return (0 until json.length()).map { index -> + valueToAny(json.opt(index)) + } } - ) - val providerProps = VerifyPurchaseWithProviderProps( - iapkit = iapkitProps, - provider = PurchaseVerificationProvider.Iapkit - ) + fun valueToAny(value: Any?): Any? { + if (value == null || value == JSONObject.NULL) return null + return when (value) { + is JSONObject -> objectToMap(value) + is JSONArray -> arrayToList(value) + else -> value + } + } + } - val result = openIap.verifyPurchaseWithProvider(providerProps) + fun normalizeProviderProps(props: Map): Map { + if (props["iapkit"] != null) return props - val response = JSONObject().apply { - put("success", true) - put("provider", result.provider.toJson()) - result.iapkit?.let { iapkit -> - put("isValid", iapkit.isValid) - put("state", iapkit.state.toJson()) - put("store", iapkit.store.toJson()) - } - result.errors?.let { errors -> - val errorsArray = JSONArray() - errors.forEach { error -> - errorsArray.put(JSONObject().apply { - put("code", error.code) - put("message", error.message) - }) + val legacyIapkit = linkedMapOf() + listOf("amazon", "apiKey", "apple", "google").forEach { key -> + if (props[key] != null) { + legacyIapkit[key] = props[key] } - put("errors", errorsArray) } - }.toString() + if (legacyIapkit.isEmpty()) return props + + return linkedMapOf( + "provider" to (props["provider"] ?: PurchaseVerificationProvider.Iapkit.toJson()), + "iapkit" to legacyIapkit + ) + } + + val propsMap = normalizeProviderProps(jsonBridge.objectToMap(JSONObject(propsJson))) + val providerProps = VerifyPurchaseWithProviderProps.fromJson(propsMap) + ?: throw IllegalArgumentException("Invalid verifyPurchaseWithProvider options") + + val result = openIap.verifyPurchaseWithProvider(providerProps) + val response = JSONObject(GodotIapHelper.sanitizeDictionary(result.toJson())) + .apply { put("success", true) } + .toString() GodotIapLog.result("verifyPurchaseWithProvider", "verified") response } catch (e: Exception) { GodotIapLog.failure("verifyPurchaseWithProvider", e) - JSONObject().apply { - put("success", false) - put("error", e.message) - }.toString() + errorResponse(e.message ?: "Verification failed") } } } diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapLog.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapLog.kt index 013ef307..ada71743 100644 --- a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapLog.kt +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapLog.kt @@ -3,6 +3,7 @@ package dev.hyo.godotiap import android.util.Log import org.json.JSONArray import org.json.JSONObject +import java.util.Locale /** * Logging utility for GodotIap plugin. @@ -11,6 +12,21 @@ import org.json.JSONObject */ internal object GodotIapLog { private const val TAG = "GodotIap" + private val SENSITIVE_KEY_FRAGMENTS = setOf( + "token", + "apikey", + "secret", + "jws", + "receiptid", + "userid", + "password", + "bearer" + ) + private val SENSITIVE_AUTH_KEYS = setOf( + "auth", + "authorization", + "authheader" + ) /** * Set to true during library development to enable debug logging. @@ -69,7 +85,21 @@ internal object GodotIapLog { private fun sanitize(value: Any?): Any? { if (value == null) return null + fun sanitizeJsonString(value: String): Any { + val trimmed = value.trim() + return try { + when { + trimmed.startsWith("{") -> sanitizeJsonObject(JSONObject(trimmed)) + trimmed.startsWith("[") -> sanitizeJsonArray(JSONArray(trimmed)) + else -> value + } + } catch (_: Exception) { + value + } + } + return when (value) { + is String -> sanitizeJsonString(value) is Map<*, *> -> sanitizeMap(value) is List<*> -> value.mapNotNull { sanitize(it) } is Array<*> -> value.mapNotNull { sanitize(it) } @@ -81,7 +111,7 @@ internal object GodotIapLog { val sanitized = linkedMapOf() for ((rawKey, rawValue) in source) { val key = rawKey as? String ?: continue - if (key.lowercase().contains("token")) { + if (isSensitiveKey(key)) { sanitized[key] = "hidden" continue } @@ -89,4 +119,40 @@ internal object GodotIapLog { } return sanitized } + + private fun sanitizeJsonObject(source: JSONObject): Map { + val sanitized = linkedMapOf() + val keys = source.keys() + while (keys.hasNext()) { + val key = keys.next() + sanitized[key] = + if (isSensitiveKey(key)) { + "hidden" + } else { + sanitizeJsonValue(source.opt(key)) + } + } + return sanitized + } + + private fun sanitizeJsonArray(source: JSONArray): List { + return (0 until source.length()).map { index -> + sanitizeJsonValue(source.opt(index)) + } + } + + private fun sanitizeJsonValue(value: Any?): Any? { + if (value == null || value == JSONObject.NULL) return null + return when (value) { + is JSONObject -> sanitizeJsonObject(value) + is JSONArray -> sanitizeJsonArray(value) + else -> sanitize(value) + } + } + + private fun isSensitiveKey(key: String): Boolean { + val normalized = key.lowercase(Locale.ROOT).filter { it.isLetterOrDigit() } + return SENSITIVE_KEY_FRAGMENTS.any { normalized.contains(it) } || + normalized in SENSITIVE_AUTH_KEYS + } } diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index a9428f0f..8eb03985 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -968,6 +968,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func verifyPurchase(propsJson: String) -> String { GodotIapLog.payload("Verifying purchase", payload: nil) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -976,6 +977,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("verifyPurchase") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant("Invalid arguments") self.productsFetched.emit(dict) @@ -989,6 +992,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("verifyPurchase") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) let resultDict = OpenIapSerialization.encode(result) if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), @@ -1001,6 +1006,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.debug("[GodotIap] verifyPurchase error: \(error.localizedDescription)") await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("verifyPurchase") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) @@ -1008,7 +1015,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } @Callable @@ -1080,6 +1087,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func verifyPurchaseWithProvider(propsJson: String) -> String { GodotIapLog.payload("Verifying purchase with provider", payload: nil) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -1088,6 +1096,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("verifyPurchaseWithProvider") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant("Invalid arguments") self.productsFetched.emit(dict) @@ -1102,6 +1112,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("verifyPurchaseWithProvider") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) let resultDict = OpenIapSerialization.encode(result) if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), @@ -1114,6 +1126,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.debug("[GodotIap] verifyPurchaseWithProvider error: \(error.localizedDescription)") await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("verifyPurchaseWithProvider") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) @@ -1121,7 +1135,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } // MARK: - StoreKit 2 Deprecated / Alias APIs diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapLog.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapLog.swift index 076770d4..a0653b77 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapLog.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapLog.swift @@ -41,6 +41,22 @@ import os /// - Note: `setEnabled` and `setHandler` should be called once at app startup /// before any logging occurs. They are not thread-safe for concurrent writes. enum GodotIapLog { + private static let sensitiveKeyFragments: Set = [ + "token", + "apikey", + "secret", + "jws", + "receiptid", + "userid", + "password", + "bearer", + ] + private static let sensitiveAuthKeys: Set = [ + "auth", + "authorization", + "authheader", + ] + enum Level: String { case debug case info @@ -128,8 +144,26 @@ enum GodotIapLog { } private static func sanitize(_ value: Any?) -> Any? { + func sanitizeJSONString(_ value: String) -> Any { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.first == "{" || trimmed.first == "[" else { + return value + } + + guard let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) else { + return value + } + + return sanitize(json) ?? value + } + guard let value else { return nil } + if let string = value as? String { + return sanitizeJSONString(string) + } + if let dictionary = value as? [String: Any] { return sanitizeDictionary(dictionary) } @@ -156,9 +190,16 @@ enum GodotIapLog { } private static func sanitizeDictionary(_ dictionary: [String: Any]) -> [String: Any] { + func isSensitiveKey(_ key: String) -> Bool { + let normalized = key.lowercased() + .filter { $0.isLetter || $0.isNumber } + return sensitiveKeyFragments.contains { normalized.contains($0) } || + sensitiveAuthKeys.contains(normalized) + } + var sanitized: [String: Any] = [:] for (key, value) in dictionary { - if key.lowercased().contains("token") { + if isSensitiveKey(key) { sanitized[key] = "hidden" } else if let sanitizedValue = sanitize(value) { sanitized[key] = sanitizedValue diff --git a/libraries/kmp-iap/example/composeApp/src/androidMain/kotlin/dev/hyo/martie/screens/PlatformTime.android.kt b/libraries/kmp-iap/example/composeApp/src/androidMain/kotlin/dev/hyo/martie/screens/PlatformTime.android.kt new file mode 100644 index 00000000..26c7966c --- /dev/null +++ b/libraries/kmp-iap/example/composeApp/src/androidMain/kotlin/dev/hyo/martie/screens/PlatformTime.android.kt @@ -0,0 +1,3 @@ +package dev.hyo.martie.screens + +internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() 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 4b4854ff..1d80bf73 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 @@ -71,7 +71,7 @@ fun AvailablePurchasesScreen(navController: NavController) { // For non-auto-renewing, check expiry time purchase.expirationDateIOS?.let { expiryTime -> val expiryDate = Instant.fromEpochMilliseconds(expiryTime.toLong()) - val now = kotlinx.datetime.Clock.System.now() + val now = Instant.fromEpochMilliseconds(currentTimeMillis()) return@filter expiryDate > now // Only show if not expired } return@filter true // Show if no expiry info diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/WebhookTestNotification.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/WebhookTestNotification.kt index 48b008e7..19436a9d 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/WebhookTestNotification.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/WebhookTestNotification.kt @@ -1,9 +1,11 @@ package dev.hyo.martie.screens -import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +internal expect fun currentTimeMillis(): Long + internal expect suspend fun triggerWebhookTestNotification( apiKey: String, baseUrl: String = "https://kit.openiap.dev", @@ -11,8 +13,8 @@ internal expect suspend fun triggerWebhookTestNotification( @OptIn(ExperimentalEncodingApi::class) internal fun buildWebhookTestNotificationPayload(messagePrefix: String): String { - val now = Clock.System.now() - val timestamp = now.toEpochMilliseconds() + val timestamp = currentTimeMillis() + val now = Instant.fromEpochMilliseconds(timestamp) val dataJson = """{"version":"1.0","packageName":"com.example.app","eventTimeMillis":"$timestamp","testNotification":{"version":"1.0"}}""" val data = Base64.Default.encode(dataJson.encodeToByteArray()) diff --git a/libraries/kmp-iap/example/composeApp/src/iosMain/kotlin/dev/hyo/martie/screens/WebhookTestNotification.ios.kt b/libraries/kmp-iap/example/composeApp/src/iosMain/kotlin/dev/hyo/martie/screens/WebhookTestNotification.ios.kt index 0088f50b..23bc4e21 100644 --- a/libraries/kmp-iap/example/composeApp/src/iosMain/kotlin/dev/hyo/martie/screens/WebhookTestNotification.ios.kt +++ b/libraries/kmp-iap/example/composeApp/src/iosMain/kotlin/dev/hyo/martie/screens/WebhookTestNotification.ios.kt @@ -1,17 +1,34 @@ package dev.hyo.martie.screens +import kotlinx.cinterop.BetaInteropApi +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine +import platform.CoreFoundation.CFAbsoluteTimeGetCurrent +import platform.Foundation.NSError import platform.Foundation.NSHTTPURLResponse import platform.Foundation.NSMutableURLRequest import platform.Foundation.NSString import platform.Foundation.NSURL import platform.Foundation.NSURLSession +import platform.Foundation.NSURLSessionConfiguration +import platform.Foundation.NSURLSessionDataDelegateProtocol +import platform.Foundation.NSURLSessionDataTask +import platform.Foundation.NSURLSessionResponseAllow import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create import platform.Foundation.dataUsingEncoding +import platform.Foundation.setHTTPBody import platform.Foundation.setHTTPMethod import platform.Foundation.setValue +import platform.darwin.NSObject import kotlin.coroutines.resume +private const val CF_ABSOLUTE_TIME_UNIX_EPOCH_OFFSET_SECONDS = 978_307_200.0 + +internal actual fun currentTimeMillis(): Long = + ((CFAbsoluteTimeGetCurrent() + CF_ABSOLUTE_TIME_UNIX_EPOCH_OFFSET_SECONDS) * 1000).toLong() + +@OptIn(BetaInteropApi::class) internal actual suspend fun triggerWebhookTestNotification( apiKey: String, baseUrl: String, @@ -22,34 +39,70 @@ internal actual suspend fun triggerWebhookTestNotification( } val url = NSURL(string = webhookTestNotificationUrl(baseUrl, apiKey)) - if (url == null) { - continuation.resume(Result.failure(IllegalArgumentException("Invalid webhook URL."))) - return@suspendCancellableCoroutine - } - val request = NSMutableURLRequest.requestWithURL(url) as NSMutableURLRequest + val request = NSMutableURLRequest.requestWithURL(url) request.setHTTPMethod("POST") request.setValue("application/json", forHTTPHeaderField = "Content-Type") - request.HTTPBody = NSString + val body = NSString .create(string = buildWebhookTestNotificationPayload("kmp-ios")) .dataUsingEncoding(NSUTF8StringEncoding) + request.setHTTPBody(body) + + val delegate = WebhookPostDelegate(continuation) + val session = NSURLSession.sessionWithConfiguration( + NSURLSessionConfiguration.defaultSessionConfiguration(), + delegate, + null, + ) + val task = session.dataTaskWithRequest(request) + continuation.invokeOnCancellation { + task.cancel() + session.invalidateAndCancel() + } + task.resume() +} - val task = NSURLSession.sharedSession.dataTaskWithRequest(request) { _, response, error -> - if (error != null) { - continuation.resume( +private class WebhookPostDelegate( + private val continuation: CancellableContinuation>, +) : NSObject(), NSURLSessionDataDelegateProtocol { + private var completed = false + + override fun URLSession( + session: NSURLSession, + dataTask: NSURLSessionDataTask, + didReceiveResponse: platform.Foundation.NSURLResponse, + completionHandler: (platform.Foundation.NSURLSessionResponseDisposition) -> Unit, + ) { + val statusCode = (didReceiveResponse as? NSHTTPURLResponse)?.statusCode?.toInt() ?: 0 + if (statusCode in 200..299) { + complete(session, Result.success(Unit)) + } else { + complete(session, Result.failure(IllegalStateException("Test POST returned $statusCode"))) + } + completionHandler(NSURLSessionResponseAllow) + } + + override fun URLSession( + session: NSURLSession, + task: platform.Foundation.NSURLSessionTask, + didCompleteWithError: NSError?, + ) { + if (didCompleteWithError != null) { + complete( + session, Result.failure( - IllegalStateException(error.localizedDescription ?: "Network error"), + IllegalStateException(didCompleteWithError.localizedDescription), ), ) - return@dataTaskWithRequest } - val statusCode = (response as? NSHTTPURLResponse)?.statusCode?.toInt() ?: 0 - if (statusCode in 200..299) { - continuation.resume(Result.success(Unit)) - } else { - continuation.resume(Result.failure(IllegalStateException("Test POST returned $statusCode"))) + } + + private fun complete(session: NSURLSession, result: Result) { + if (completed) { + return } + completed = true + continuation.resume(result) + session.finishTasksAndInvalidate() } - continuation.invokeOnCancellation { task.cancel() } - task.resume() } diff --git a/libraries/kmp-iap/example/composeApp/src/jvmMain/kotlin/dev/hyo/martie/screens/PlatformTime.jvm.kt b/libraries/kmp-iap/example/composeApp/src/jvmMain/kotlin/dev/hyo/martie/screens/PlatformTime.jvm.kt new file mode 100644 index 00000000..26c7966c --- /dev/null +++ b/libraries/kmp-iap/example/composeApp/src/jvmMain/kotlin/dev/hyo/martie/screens/PlatformTime.jvm.kt @@ -0,0 +1,3 @@ +package dev.hyo.martie.screens + +internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/libraries/kmp-iap/gradle.properties b/libraries/kmp-iap/gradle.properties index 2deac08a..deb4ce9b 100644 --- a/libraries/kmp-iap/gradle.properties +++ b/libraries/kmp-iap/gradle.properties @@ -8,7 +8,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true #MPP kotlin.mpp.enableCInteropCommonization=true #Version -libraryVersion=2.3.1 +libraryVersion=2.4.0-rc.1 #Android android.useAndroidX=true android.nonTransitiveRClass=true diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 3e21680d..0028afec 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -92,9 +92,10 @@ import io.github.hyochan.kmpiap.openiap.ExternalLinkTypeAndroid import io.github.hyochan.kmpiap.openiap.LaunchExternalLinkParamsAndroid import io.github.hyochan.kmpiap.openiap.SubscriptionProductReplacementParamsAndroid import io.github.hyochan.kmpiap.openiap.SubscriptionReplacementModeAndroid -import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps as GoogleVerifyPurchaseWithIapkitProps -import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps as GoogleVerifyPurchaseWithIapkitGoogleProps -import dev.hyo.openiap.utils.verifyPurchaseWithIapkit as verifyPurchaseWithIapkitGoogle +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitAmazonProps as AndroidVerifyPurchaseWithIapkitAmazonProps +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps as AndroidVerifyPurchaseWithIapkitGoogleProps +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps as AndroidVerifyPurchaseWithIapkitProps +import dev.hyo.openiap.utils.verifyPurchaseWithIapkit as verifyPurchaseWithIapkitAndroid import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withTimeout import kotlinx.coroutines.channels.BufferOverflow @@ -1152,28 +1153,46 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife message = "IAPKit options are required for Android verification" ) ) - val googleOptions = iapkitOptions.google ?: failWith( - PurchaseError( - code = ErrorCode.PurchaseVerificationFailed, - message = "Google purchaseToken is required for Android verification" + val payloadCount = listOfNotNull( + iapkitOptions.apple, + iapkitOptions.google, + iapkitOptions.amazon + ).size + val googleOptions = iapkitOptions.google + val amazonOptions = iapkitOptions.amazon + if (payloadCount != 1 || (googleOptions == null && amazonOptions == null)) { + failWith( + PurchaseError( + code = ErrorCode.PurchaseVerificationFailed, + message = "IAPKit verification on KMP Android requires exactly one google or amazon payload" + ) ) - ) + } return try { - val openIapProps = GoogleVerifyPurchaseWithIapkitProps( + val openIapProps = AndroidVerifyPurchaseWithIapkitProps( apiKey = iapkitOptions.apiKey, apple = null, - google = GoogleVerifyPurchaseWithIapkitGoogleProps( - purchaseToken = googleOptions.purchaseToken - ) + amazon = amazonOptions?.let { amazon -> + AndroidVerifyPurchaseWithIapkitAmazonProps( + receiptId = amazon.receiptId, + sandbox = amazon.sandbox, + userId = amazon.userId + ) + }, + google = googleOptions?.let { google -> + AndroidVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = google.purchaseToken + ) + } ) - val googleResult = verifyPurchaseWithIapkitGoogle(openIapProps, "kmp-iap-android") + val androidResult = verifyPurchaseWithIapkitAndroid(openIapProps, "kmp-iap-android") val iapkitResult = RequestVerifyPurchaseWithIapkitResult( - isValid = googleResult.isValid, - state = IapkitPurchaseState.fromJson(googleResult.state.toJson()), - store = IapStore.fromJson(googleResult.store.toJson()) + isValid = androidResult.isValid, + state = IapkitPurchaseState.fromJson(androidResult.state.toJson()), + store = IapStore.fromJson(androidResult.store.toJson()) ) VerifyPurchaseWithProviderResult( diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt index 9cfb5374..2b4ef8ae 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt @@ -77,6 +77,7 @@ typealias PurchaseVerificationProvider = io.github.hyochan.kmpiap.openiap.Purcha typealias VerifyPurchaseWithProviderProps = io.github.hyochan.kmpiap.openiap.VerifyPurchaseWithProviderProps typealias VerifyPurchaseWithProviderResult = io.github.hyochan.kmpiap.openiap.VerifyPurchaseWithProviderResult typealias RequestVerifyPurchaseWithIapkitProps = io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitProps +typealias RequestVerifyPurchaseWithIapkitAmazonProps = io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitAmazonProps typealias RequestVerifyPurchaseWithIapkitAppleProps = io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitAppleProps typealias RequestVerifyPurchaseWithIapkitGoogleProps = io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitGoogleProps typealias RequestVerifyPurchaseWithIapkitResult = io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 6e4ffda4..5abcc05c 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -629,7 +629,8 @@ public enum class IapStore(val rawValue: String) { Unknown("unknown"), Apple("apple"), Google("google"), - Horizon("horizon"); + Horizon("horizon"), + Amazon("amazon"); companion object { fun fromJson(value: String): IapStore = when (value) { @@ -645,6 +646,9 @@ public enum class IapStore(val rawValue: String) { "horizon" -> IapStore.Horizon "HORIZON" -> IapStore.Horizon "Horizon" -> IapStore.Horizon + "amazon" -> IapStore.Amazon + "AMAZON" -> IapStore.Amazon + "Amazon" -> IapStore.Amazon else -> throw IllegalArgumentException("Unknown IapStore value: $value") } } @@ -4621,7 +4625,8 @@ public data class RequestPurchaseProps( * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ public data class RequestPurchasePropsByPlatforms( @@ -4825,7 +4830,8 @@ public data class RequestSubscriptionIosProps( * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ public data class RequestSubscriptionPropsByPlatforms( @@ -4865,6 +4871,41 @@ public data class RequestSubscriptionPropsByPlatforms( ) } +public data class RequestVerifyPurchaseWithIapkitAmazonProps( + /** + * Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + */ + val receiptId: String, + /** + * Use Amazon RVS Cloud Sandbox for App Tester receipts. + */ + val sandbox: Boolean? = null, + /** + * Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + */ + val userId: String? = null +) { + companion object { + fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAmazonProps? { + val receiptId = json["receiptId"] as? String + val sandbox = json["sandbox"] as? Boolean + val userId = json["userId"] as? String + if (receiptId == null) return null + return RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId = receiptId, + sandbox = sandbox, + userId = userId, + ) + } + } + + fun toJson(): Map = mapOf( + "receiptId" to receiptId, + "sandbox" to sandbox, + "userId" to userId, + ) +} + public data class RequestVerifyPurchaseWithIapkitAppleProps( /** * The JWS token returned with the purchase response. @@ -4912,8 +4953,13 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps( * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) */ public data class RequestVerifyPurchaseWithIapkitProps( + /** + * Amazon Appstore verification parameters. + */ + val amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = null, /** * API key used for the Authorization header (Bearer {apiKey}). */ @@ -4930,6 +4976,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitProps { return RequestVerifyPurchaseWithIapkitProps( + amazon = (json["amazon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(it) }, apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, @@ -4938,6 +4985,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( } fun toJson(): Map = mapOf( + "amazon" to amazon?.toJson(), "apiKey" to apiKey, "apple" to apple?.toJson(), "google" to google?.toJson(), diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 0ab9a9f2..ca4b4922 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -967,11 +967,35 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { * * @see https://openiap.dev/docs/features/validation#verify-purchase-with-provider */ - override suspend fun verifyPurchaseWithProvider(options: VerifyPurchaseWithProviderProps): VerifyPurchaseWithProviderResult = - suspendCoroutine { continuation -> + override suspend fun verifyPurchaseWithProvider(options: VerifyPurchaseWithProviderProps): VerifyPurchaseWithProviderResult { + if (options.provider != PurchaseVerificationProvider.Iapkit) { + throw PurchaseException( + PurchaseError( + code = ErrorCode.FeatureNotSupported, + message = "Verification provider ${options.provider.rawValue} is not supported on iOS" + ) + ) + } + val iapkit = options.iapkit ?: throw PurchaseException( + PurchaseError( + code = ErrorCode.PurchaseVerificationFailed, + message = "IAPKit options are required for iOS verification" + ) + ) + val payloadCount = listOfNotNull(iapkit.apple, iapkit.google, iapkit.amazon).size + if (payloadCount != 1 || iapkit.apple == null) { + throw PurchaseException( + PurchaseError( + code = ErrorCode.PurchaseVerificationFailed, + message = "IAPKit verification on KMP iOS requires exactly one apple payload" + ) + ) + } + + return suspendCoroutine { continuation -> val provider = options.provider.rawValue - val apiKey = options.iapkit?.apiKey - val jws = options.iapkit?.apple?.jws + val apiKey = iapkit.apiKey + val jws = iapkit.apple.jws openIapModule.verifyPurchaseWithProviderObjCWithProvider( provider = provider, @@ -1005,30 +1029,25 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { try { val map = (result as? Map<*, *>)?.mapKeys { it.key.toString() } - - val iapkitResult = if (map != null) { - val isValid = map["isValid"] as? Boolean ?: false - val stateString = map["state"] as? String ?: "unknown" - val storeString = map["store"] as? String ?: "apple" - - val state = try { - IapkitPurchaseState.fromJson(stateString) - } catch (e: IllegalArgumentException) { - IapkitPurchaseState.Unknown - } - - val store = try { - IapStore.fromJson(storeString) - } catch (e: IllegalArgumentException) { - IapStore.Apple - } - - RequestVerifyPurchaseWithIapkitResult( - isValid = isValid, - state = state, - store = store - ) - } else null + ?: throw IllegalArgumentException("IAPKit result must be an object") + val isValid = map["isValid"] as? Boolean + ?: throw IllegalArgumentException("IAPKit result missing isValid") + val stateString = map["state"] as? String + ?: throw IllegalArgumentException("IAPKit result missing state") + val storeString = map["store"] as? String + ?: throw IllegalArgumentException("IAPKit result missing store") + val state = runCatching { + IapkitPurchaseState.fromJson(stateString) + }.getOrDefault(IapkitPurchaseState.Unknown) + val store = IapStore.fromJson(storeString) + if (store != IapStore.Apple) { + throw IllegalArgumentException("IAPKit result store mismatch: $storeString") + } + val iapkitResult = RequestVerifyPurchaseWithIapkitResult( + isValid = isValid, + state = state, + store = store + ) continuation.resume( VerifyPurchaseWithProviderResult( @@ -1048,6 +1067,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } } + } // ------------------------------------------------------------------------- // SubscriptionResolver Implementation diff --git a/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt b/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt index 7826bb9e..38c5c013 100644 --- a/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt +++ b/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt @@ -115,7 +115,7 @@ class OpenIapMauiModule(context: Context) { // ----------------------------------------------------------------- fun fetchProducts(paramsJson: String, callback: ResultCallback) = run(callback) { - val params = ProductRequest.fromJson(parseMap(paramsJson)) ?: throw badInput("ProductRequest", paramsJson) + val params = ProductRequest.fromJson(parseMap(paramsJson)) ?: throw badInput("ProductRequest") encodeFetchProductsResult(module.fetchProducts(params)) } @@ -183,7 +183,7 @@ class OpenIapMauiModule(context: Context) { fun verifyPurchaseWithProvider(propsJson: String, callback: ResultCallback) = run(callback) { val props = VerifyPurchaseWithProviderProps.fromJson(parseMap(propsJson)) - ?: throw badInput("VerifyPurchaseWithProviderProps", propsJson) + ?: throw badInput("VerifyPurchaseWithProviderProps") gson.toJson(module.verifyPurchaseWithProvider(props).toJson()) } @@ -224,7 +224,7 @@ class OpenIapMauiModule(context: Context) { fun launchExternalLinkAndroid(paramsJson: String, callback: ResultCallback) = run(callback) { val params = LaunchExternalLinkParamsAndroid.fromJson(parseMap(paramsJson)) - ?: throw badInput("LaunchExternalLinkParamsAndroid", paramsJson) + ?: throw badInput("LaunchExternalLinkParamsAndroid") val activity = currentActivityOrThrow("launchExternalLinkAndroid") wrapBool(module.launchExternalLink(activity, params)) } @@ -431,9 +431,9 @@ class OpenIapMauiModule(context: Context) { return gson.toJson(payload) } - private fun badInput(typeName: String, json: String): IllegalArgumentException { + private fun badInput(typeName: String): IllegalArgumentException { // The generated `Type.fromJson(Map)` returns null when required fields are missing. // Surface this as a typed error so the C# side can map it to OpenIapError.DeveloperError. - return IllegalArgumentException("Could not decode $typeName from JSON: $json") + return IllegalArgumentException("Could not decode $typeName from JSON") } } diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs index fb06f43b..f230f0fa 100644 --- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs +++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs @@ -124,6 +124,12 @@ void VerifyPurchaseWithProvider( [NullAllowed] string jws, Action completion); + [Export("verifyPurchaseWithProviderObjCWithPayload:completion:")] + [Async] + void VerifyPurchaseWithProviderPayload( + NSDictionary payload, + Action completion); + // -- Storefront / Subscriptions ---------------------------------------- [Export("getStorefrontWithCompletion:")] diff --git a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj index 92b6fc3f..c9aadfb1 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj +++ b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj @@ -21,7 +21,7 @@ OpenIap.Maui OpenIap.Maui OpenIap.Maui - 1.1.1 + 1.2.0-rc.1 OpenIAP for .NET MAUI OpenIAP — unified in-app purchases for .NET MAUI (iOS / Android). Mirrors the OpenIAP GraphQL schema and shares the same API surface as react-native-iap, expo-iap, flutter_inapp_purchase, and kmp-iap. hyodotdev diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs index 37613607..ddd239bb 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -372,22 +372,19 @@ public Task VerifyPurchaseAsync(VerifyPurchaseProps option public Task VerifyPurchaseWithProviderAsync(VerifyPurchaseWithProviderProps options) { - var providerEnum = options.Provider; - var apiKey = options.Iapkit?.ApiKey; - var jws = options.Iapkit?.Apple?.Jws; + var payloadNode = JsonSerializer.SerializeToNode(options, JsonOptions.Default) as JsonObject + ?? throw OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "Invalid verifyPurchaseWithProvider payload"); + var payload = NSObjectJsonBridge.JsonObjectToDictionary(payloadNode); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _module.VerifyPurchaseWithProvider(providerEnum.ToJson(), apiKey, jws, (dict, err) => + _module.VerifyPurchaseWithProviderPayload(payload, (dict, err) => { try { if (err is not null) { tcs.TrySetException(MapNSError(err)); return; } var node = NSObjectJsonBridge.DictToObject(dict); - var typed = node?.Deserialize(JsonOptions.Default); - tcs.TrySetResult(new VerifyPurchaseWithProviderResult - { - Provider = providerEnum, - Iapkit = typed, - }); + var typed = node?.Deserialize(JsonOptions.Default); + if (typed is not null) tcs.TrySetResult(typed); + else tcs.TrySetException(OpenIapErrorMapper.Wrap(ErrorCode.Unknown, "verifyPurchaseWithProvider returned no payload")); } catch (Exception ex) { tcs.TrySetException(ex); } }); diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index c35b1bd3..05aab794 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -978,7 +978,8 @@ public enum IapStore Unknown, Apple, Google, - Horizon + Horizon, + Amazon } public sealed class IapStoreJsonConverter : JsonConverter @@ -997,6 +998,9 @@ public sealed class IapStoreJsonConverter : JsonConverter ["horizon"] = IapStore.Horizon, ["HORIZON"] = IapStore.Horizon, ["Horizon"] = IapStore.Horizon, + ["amazon"] = IapStore.Amazon, + ["AMAZON"] = IapStore.Amazon, + ["Amazon"] = IapStore.Amazon, }; private static readonly Dictionary _toString = new() @@ -1005,6 +1009,7 @@ public sealed class IapStoreJsonConverter : JsonConverter [IapStore.Apple] = "apple", [IapStore.Google] = "google", [IapStore.Horizon] = "horizon", + [IapStore.Amazon] = "amazon", }; public override IapStore Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -3846,7 +3851,8 @@ public sealed record RequestPurchaseProps /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) public sealed record RequestPurchasePropsByPlatforms { @@ -3943,7 +3949,8 @@ public sealed record RequestSubscriptionIosProps /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) public sealed record RequestSubscriptionPropsByPlatforms { @@ -3961,6 +3968,19 @@ public sealed record RequestSubscriptionPropsByPlatforms public RequestSubscriptionAndroidProps? Android { get; init; } } +public sealed record RequestVerifyPurchaseWithIapkitAmazonProps +{ + /// Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + [JsonPropertyName("userId")] + public string? UserId { get; init; } + /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + [JsonPropertyName("receiptId")] + public required string ReceiptId { get; init; } + /// Use Amazon RVS Cloud Sandbox for App Tester receipts. + [JsonPropertyName("sandbox")] + public bool? Sandbox { get; init; } +} + public sealed record RequestVerifyPurchaseWithIapkitAppleProps { /// The JWS token returned with the purchase response. @@ -3979,6 +3999,7 @@ public sealed record RequestVerifyPurchaseWithIapkitGoogleProps /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) public sealed record RequestVerifyPurchaseWithIapkitProps { /// API key used for the Authorization header (Bearer {apiKey}). @@ -3990,6 +4011,9 @@ public sealed record RequestVerifyPurchaseWithIapkitProps /// Google Play Store verification parameters. [JsonPropertyName("google")] public RequestVerifyPurchaseWithIapkitGoogleProps? Google { get; init; } + /// Amazon Appstore verification parameters. + [JsonPropertyName("amazon")] + public RequestVerifyPurchaseWithIapkitAmazonProps? Amazon { get; init; } } /// Product-level subscription replacement parameters (Android) diff --git a/libraries/react-native-iap/README.md b/libraries/react-native-iap/README.md index 9daa6d56..ecb43f6f 100644 --- a/libraries/react-native-iap/README.md +++ b/libraries/react-native-iap/README.md @@ -105,7 +105,7 @@ Before installing React Native IAP, make sure you have: #### Android Configuration -**Kotlin Version Requirement:** This library requires Kotlin 2.0+. Configure your project's Kotlin version: +**Kotlin Version Requirement:** This library requires Kotlin 2.2+. Configure your project's Kotlin version: In your root `android/build.gradle`: diff --git a/libraries/react-native-iap/android/build.gradle b/libraries/react-native-iap/android/build.gradle index 28c4de04..96147102 100644 --- a/libraries/react-native-iap/android/build.gradle +++ b/libraries/react-native-iap/android/build.gradle @@ -107,8 +107,13 @@ def getExtOrIntegerDefault(name) { return getExtOrDefault(name).toString().toInteger() } -// Read horizonEnabled from gradle.properties, default to false (play) +// 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') def resolveOpenIapGoogleBuildFile() { def candidates = [ @@ -155,9 +160,8 @@ android { // Ship consumer keep rules so Nitro HybridObjects aren't stripped in app release builds consumerProguardFiles 'consumer-rules.pro' - // Use horizonEnabled to determine platform flavor - def flavor = horizonEnabled ? 'horizon' : 'play' - missingDimensionStrategy "platform", flavor + // Use explicit store flags to determine platform flavor + missingDimensionStrategy "platform", openiapFlavor externalNativeBuild { cmake { @@ -251,7 +255,9 @@ dependencies { } // Google Play Services - implementation "com.google.android.gms:play-services-base:$playServicesBaseVersion" + if (!fireOsEnabled && !horizonEnabled) { + implementation "com.google.android.gms:play-services-base:$playServicesBaseVersion" + } // Kotlin coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" @@ -261,6 +267,8 @@ dependencies { def localGoogleProject = findProject(':openiap') if (localGoogleProject != null) { implementation project(':openiap') + } else if (fireOsEnabled) { + implementation "io.github.hyochan.openiap:openiap-google-amazon:${googleVersionString}" } else if (horizonEnabled) { implementation "io.github.hyochan.openiap:openiap-google-horizon:${googleVersionString}" } else { 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 390e34fd..ae5e7955 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 @@ -43,13 +43,13 @@ import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunc import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.openiap.store.OpenIapStore +import java.util.Locale import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.CompletableDeferred import org.json.JSONArray import org.json.JSONObject -import java.util.Locale /** * Custom exception for OpenIAP errors that only includes the error JSON without stack traces. @@ -575,7 +575,7 @@ class HybridRnIap : HybridRnIapSpec() { mapOf("type" to androidOptions?.type?.name, "includeSuspended" to includeSuspended) ) - val typeName = androidOptions?.type?.name?.lowercase() + val typeName = androidOptions?.type?.name?.lowercase(java.util.Locale.ROOT) val normalizedType = when (typeName) { "inapp" -> { RnIapLog.warn("getAvailablePurchases received legacy type 'inapp'; forwarding as 'in-app'") @@ -1279,6 +1279,7 @@ class HybridRnIap : HybridRnIapSpec() { dev.hyo.openiap.IapStore.Apple -> IapStore.APPLE dev.hyo.openiap.IapStore.Google -> IapStore.GOOGLE dev.hyo.openiap.IapStore.Horizon -> IapStore.HORIZON + dev.hyo.openiap.IapStore.Amazon -> IapStore.AMAZON dev.hyo.openiap.IapStore.Unknown -> IapStore.UNKNOWN } } @@ -1469,7 +1470,7 @@ class HybridRnIap : HybridRnIapSpec() { return Promise.async { try { // Convert Nitro enum to string (e.g., IAPKIT -> "iapkit") - val providerString = params.provider.name.lowercase() + val providerString = params.provider.name.lowercase(java.util.Locale.ROOT) RnIapLog.payload("verifyPurchaseWithProvider", mapOf("provider" to providerString)) // Build the props map for OpenIAP - use string value for provider @@ -1482,6 +1483,14 @@ class HybridRnIap : HybridRnIapSpec() { (iapkit.google as? Variant_NullType_NitroVerifyPurchaseWithIapkitGoogleProps.Second)?.value?.let { google -> iapkitMap["google"] = mapOf("purchaseToken" to google.purchaseToken) } + (iapkit.amazon as? Variant_NullType_NitroVerifyPurchaseWithIapkitAmazonProps.Second)?.value?.let { amazon -> + val amazonMap = mutableMapOf( + "receiptId" to amazon.receiptId + ) + amazon.userId.unwrapString()?.let { amazonMap["userId"] = it } + amazon.sandbox.unwrapBool()?.let { amazonMap["sandbox"] = it } + iapkitMap["amazon"] = amazonMap + } (iapkit.apple as? Variant_NullType_NitroVerifyPurchaseWithIapkitAppleProps.Second)?.value?.let { apple -> iapkitMap["apple"] = mapOf("jws" to apple.jws) } @@ -1609,6 +1618,7 @@ class HybridRnIap : HybridRnIapSpec() { // Alternative Billing (Android) // ------------------------------------------------------------------------- + @Suppress("DEPRECATION") override fun checkAlternativeBillingAvailabilityAndroid(): Promise { return Promise.async { RnIapLog.payload("checkAlternativeBillingAvailabilityAndroid", null) @@ -1626,6 +1636,7 @@ class HybridRnIap : HybridRnIapSpec() { } } + @Suppress("DEPRECATION") override fun showAlternativeBillingDialogAndroid(): Promise { return Promise.async { RnIapLog.payload("showAlternativeBillingDialogAndroid", null) @@ -1647,6 +1658,7 @@ class HybridRnIap : HybridRnIapSpec() { } } + @Suppress("DEPRECATION") override fun createAlternativeBillingTokenAndroid(sku: Variant_NullType_String?): Promise { return Promise.async { val skuValue = sku.unwrapString() @@ -2000,6 +2012,7 @@ class HybridRnIap : HybridRnIapSpec() { "APPLE" -> IapStore.APPLE "GOOGLE" -> IapStore.GOOGLE "HORIZON" -> IapStore.HORIZON + "AMAZON" -> IapStore.AMAZON else -> IapStore.UNKNOWN } } diff --git a/libraries/react-native-iap/example/.env.example b/libraries/react-native-iap/example/.env.example index be95248c..9410244b 100644 --- a/libraries/react-native-iap/example/.env.example +++ b/libraries/react-native-iap/example/.env.example @@ -1,3 +1,6 @@ # IAPKit Configuration # Get your API key from https://kit.openiap.dev IAPKIT_API_KEY=your_iapkit_api_key_here +# Use your Mac's LAN IP for Vega / Fire TV device testing. +# Example: http://192.168.0.10:3100 +IAPKIT_BASE_URL= diff --git a/libraries/react-native-iap/example/.eslintrc.js b/libraries/react-native-iap/example/.eslintrc.js index bc50ef22..7e6dc2f3 100644 --- a/libraries/react-native-iap/example/.eslintrc.js +++ b/libraries/react-native-iap/example/.eslintrc.js @@ -2,6 +2,9 @@ module.exports = { root: true, extends: ['expo', 'prettier'], plugins: ['prettier'], + settings: { + 'import/core-modules': ['@env'], + }, rules: { 'eslint-comments/no-unlimited-disable': 0, 'eslint-comments/no-unused-disable': 0, @@ -14,7 +17,7 @@ module.exports = { }, }, { - files: ['react-native.config.js', 'metro.config.js'], + files: ['react-native.config.js', 'metro.config.js', 'scripts/*.{js,mjs}'], env: { node: true, }, diff --git a/libraries/react-native-iap/example/App.kepler.tsx b/libraries/react-native-iap/example/App.kepler.tsx new file mode 100644 index 00000000..df1baa4e --- /dev/null +++ b/libraries/react-native-iap/example/App.kepler.tsx @@ -0,0 +1,157 @@ +import React, {useMemo, useState} from 'react'; +import { + LogBox, + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import AllProducts from './screens/AllProducts'; +import AlternativeBilling from './screens/AlternativeBilling'; +import AvailablePurchases from './screens/AvailablePurchases'; +import Home from './screens/Home'; +import OfferCode from './screens/OfferCode'; +import PurchaseFlow from './screens/PurchaseFlow'; +import SubscriptionFlow from './screens/SubscriptionFlow'; +import WebhookStream from './screens/WebhookStream'; +import {DataModalProvider} from './src/contexts/DataModalContext'; + +LogBox.ignoreLogs([ + 'Legacy AsyncStorage is on a deprecation path', + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + '[AmazonIAPSDK] Response status for GetUserData : FAILED', + '[AmazonIAPSDK] Response status for GetProductData : FAILED', + '[AmazonIAPSDK] Response status for GetPurchaseUpdates : FAILED', + '[RN-IAP] Error fetching products:', + '[RN-IAP] Error fetching available purchases:', + '[RN-IAP] Error getting active subscriptions:', +]); + +(global as any).RN_IAP_DEV_MODE = true; +(global as any).RN_IAP_SUPPRESS_NATIVE_ALERTS = true; + +type RouteName = + | 'Home' + | 'AllProducts' + | 'PurchaseFlow' + | 'SubscriptionFlow' + | 'AvailablePurchases' + | 'OfferCode' + | 'AlternativeBilling' + | 'WebhookStream'; + +const ROUTE_TITLES: Record = { + Home: 'React Native IAP', + AllProducts: 'All Products', + PurchaseFlow: 'Purchase Flow', + SubscriptionFlow: 'Subscription Flow', + AvailablePurchases: 'Available Purchases', + OfferCode: 'Offer Code', + AlternativeBilling: 'Alternative Billing', + WebhookStream: 'Webhook Stream', +}; + +const SCREENS: Record, React.ComponentType> = { + AllProducts, + PurchaseFlow, + SubscriptionFlow, + AvailablePurchases, + OfferCode, + AlternativeBilling, + WebhookStream, +}; + +export default function App(): React.JSX.Element { + const [stack, setStack] = useState(['Home']); + const route = stack[stack.length - 1] ?? 'Home'; + const canGoBack = stack.length > 1; + + const navigation = useMemo( + () => ({ + navigate(nextRoute: RouteName) { + setStack((currentStack) => [...currentStack, nextRoute]); + }, + goBack() { + setStack((currentStack) => + currentStack.length > 1 ? currentStack.slice(0, -1) : currentStack, + ); + }, + canGoBack() { + return stack.length > 1; + }, + }), + [stack.length], + ); + + const Screen = route === 'Home' ? null : SCREENS[route]; + + return ( + + + {canGoBack ? ( + + + Back + + + {ROUTE_TITLES[route]} + + + + ) : null} + + + {route === 'Home' ? ( + + ) : Screen ? ( + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + content: { + flex: 1, + }, + header: { + alignItems: 'center', + backgroundColor: '#fff', + borderBottomColor: '#e0e0e0', + borderBottomWidth: 1, + flexDirection: 'row', + minHeight: 56, + paddingHorizontal: 12, + }, + backButton: { + minWidth: 72, + paddingHorizontal: 12, + paddingVertical: 10, + }, + backButtonText: { + color: '#2563eb', + fontSize: 16, + fontWeight: '600', + }, + headerTitle: { + color: '#333', + flex: 1, + fontSize: 18, + fontWeight: '700', + textAlign: 'center', + }, + headerSpacer: { + minWidth: 72, + }, +}); diff --git a/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx b/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx new file mode 100644 index 00000000..f4cd1c91 --- /dev/null +++ b/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx @@ -0,0 +1,46 @@ +import {fireEvent, render} from '@testing-library/react-native'; +import {Platform} from 'react-native'; +import * as RNIap from 'react-native-iap'; +import AlternativeBilling from '../../screens/AlternativeBilling'; + +describe('AlternativeBilling Screen', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + (RNIap.useIAP as jest.Mock).mockReturnValue({ + connected: true, + products: [ + { + id: 'dev.hyo.martie.consumable', + title: 'Test Consumable', + description: 'Test consumable description', + displayPrice: '$0.99', + type: 'in-app', + }, + ], + fetchProducts: jest.fn(() => Promise.resolve([])), + finishTransaction: jest.fn(() => Promise.resolve()), + }); + }); + + afterEach(() => { + (Platform as any).OS = originalPlatform; + }); + + it('renders Amazon Vega as unsupported for alternative billing', () => { + (Platform as any).OS = 'kepler'; + + const {getByText} = render(); + + expect(getByText('Not supported on Amazon Vega')).toBeTruthy(); + expect( + getByText(/Alternative billing APIs are intentionally unsupported/), + ).toBeTruthy(); + expect(getByText('Current mode: Amazon Vega standard IAP')).toBeTruthy(); + + fireEvent.press(getByText('Test Consumable')); + + expect(getByText('Not supported on Vega')).toBeTruthy(); + }); +}); 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 4f596440..57ab3635 100644 --- a/libraries/react-native-iap/example/__tests__/screens/AvailablePurchases.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/AvailablePurchases.test.tsx @@ -1,6 +1,6 @@ import {type ReactElement} from 'react'; import {render, fireEvent, waitFor} from '@testing-library/react-native'; -import {Alert} from 'react-native'; +import {Alert, Platform} from 'react-native'; import AvailablePurchases from '../../screens/AvailablePurchases'; import * as RNIap from 'react-native-iap'; import {DataModalProvider} from '../../src/contexts/DataModalContext'; @@ -108,6 +108,24 @@ describe('AvailablePurchases Screen', () => { expect(getByText('🔄 Active Subscriptions')).toBeTruthy(); }); + it('shows Vega guidance instead of opening unsupported subscription management deep links', () => { + const originalPlatform = Platform.OS; + (Platform as any).OS = 'kepler'; + + try { + const {getByText} = renderWithProviders(); + + fireEvent.press(getByText('👤 Manage Subscriptions')); + + expect( + getByText(/Subscription management deep links are not exposed/), + ).toBeTruthy(); + expect(RNIap.deepLinkToSubscriptions).not.toHaveBeenCalled(); + } finally { + (Platform as any).OS = originalPlatform; + } + }); + it.skip('handles error when fetching purchases fails', async () => { mockGetAvailablePurchases.mockRejectedValueOnce( new Error('Failed to fetch purchases'), 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 8673a00d..48bdd604 100644 --- a/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx @@ -87,6 +87,18 @@ describe('OfferCode Screen', () => { expect(getByText('🎁 Open Play Store')).toBeTruthy(); }); + it('shows Vega unsupported guidance without calling platform redemption APIs', () => { + (Platform as any).OS = 'kepler'; + const {getByText} = render(); + + fireEvent.press(getByText('Amazon Vega IAP')); + + expect(mockPresentCodeRedemptionSheetIOS).not.toHaveBeenCalled(); + expect( + getByText(/Offer code redemption is not supported on Amazon Vega/), + ).toBeTruthy(); + }); + it('shows testing offer codes section', () => { const {getByText} = render(); 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 eebc1dd5..8731230a 100644 --- a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx @@ -198,6 +198,16 @@ describe('PurchaseFlow Screen', () => { const fetchProducts = jest.fn(() => Promise.resolve()); const getAvailablePurchases = jest.fn(() => Promise.resolve()); const finishTransaction = jest.fn(() => Promise.resolve()); + const verifyPurchase = jest.fn(() => Promise.resolve({})); + const verifyPurchaseWithProvider = jest.fn(() => + Promise.resolve({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'amazon', + }, + }), + ); (RNIap.useIAP as jest.Mock).mockImplementation((options) => { onPurchaseSuccess = options?.onPurchaseSuccess; @@ -211,11 +221,19 @@ describe('PurchaseFlow Screen', () => { fetchProducts, finishTransaction, getAvailablePurchases, + verifyPurchase, + verifyPurchaseWithProvider, ...overrides, }; }); - return {fetchProducts, getAvailablePurchases, finishTransaction}; + return { + fetchProducts, + getAvailablePurchases, + finishTransaction, + verifyPurchase, + verifyPurchaseWithProvider, + }; }; beforeEach(() => { @@ -272,11 +290,11 @@ describe('PurchaseFlow Screen', () => { }); it('updates state on purchase success callback', async () => { - mockIapState(); + const {finishTransaction} = mockIapState(); const {getByText, queryByText} = render(); - expect(queryByText(/Purchase completed successfully/)).toBeNull(); + expect(queryByText(/Purchase completed and finished successfully/)).toBeNull(); await act(async () => { await onPurchaseSuccess?.({ @@ -288,7 +306,16 @@ describe('PurchaseFlow Screen', () => { }); await waitFor(() => { - expect(getByText(/Purchase completed successfully/)).toBeTruthy(); + expect( + getByText(/Purchase completed and finished successfully/), + ).toBeTruthy(); + }); + expect(finishTransaction).toHaveBeenCalledWith({ + purchase: expect.objectContaining({ + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'token-123', + }), + isConsumable: true, }); expect(alertSpy).toHaveBeenCalledWith( 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 5aa8d6d3..190ab095 100644 --- a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx @@ -32,6 +32,16 @@ describe('SubscriptionFlow Screen', () => { const defaultGetAvailablePurchases = jest.fn(() => Promise.resolve([])); const getActiveSubscriptions = jest.fn(() => Promise.resolve([])); const finishTransaction = jest.fn(() => Promise.resolve()); + const verifyPurchase = jest.fn(() => Promise.resolve({})); + const verifyPurchaseWithProvider = jest.fn(() => + Promise.resolve({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'amazon', + }, + }), + ); // Use the override if provided, otherwise use default const getAvailablePurchases = @@ -50,6 +60,8 @@ describe('SubscriptionFlow Screen', () => { finishTransaction, getAvailablePurchases, getActiveSubscriptions, + verifyPurchase, + verifyPurchaseWithProvider, ...overrides, }; // Ensure getAvailablePurchases uses our mock @@ -62,6 +74,8 @@ describe('SubscriptionFlow Screen', () => { getAvailablePurchases, getActiveSubscriptions, finishTransaction, + verifyPurchase, + verifyPurchaseWithProvider, }; }; diff --git a/libraries/react-native-iap/example/__tests__/screens/WebhookStream.test.tsx b/libraries/react-native-iap/example/__tests__/screens/WebhookStream.test.tsx index bbf0f31c..5c8c7f71 100644 --- a/libraries/react-native-iap/example/__tests__/screens/WebhookStream.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/WebhookStream.test.tsx @@ -1,5 +1,8 @@ import {fireEvent, render, waitFor} from '@testing-library/react-native'; import WebhookStream, {base64EncodeUtf8} from '../../screens/WebhookStream'; +import * as RNIap from 'react-native-iap'; + +const mockConnectWebhookStream = RNIap.connectWebhookStream as jest.Mock; describe('WebhookStream Screen', () => { beforeEach(() => { @@ -45,6 +48,53 @@ describe('WebhookStream Screen', () => { }); }); + it('connects, renders incoming events, and triggers a test notification when configured', async () => { + mockConnectWebhookStream.mockImplementationOnce((options) => { + options.onEvent({ + id: 'event-1', + type: 'TestNotification', + source: 'google-play-real-time-developer-notifications', + platform: 'Android', + environment: 'Sandbox', + projectId: 'project-1', + occurredAt: Date.now(), + receivedAt: Date.now(), + productId: 'dev.hyo.martie.premium', + }); + return {close: jest.fn()}; + }); + + const {getByText} = render( + , + ); + + fireEvent.press(getByText('Connect')); + + await waitFor(() => { + expect(mockConnectWebhookStream).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-key', + baseUrl: 'http://localhost:8787', + }), + ); + expect(getByText('TestNotification')).toBeTruthy(); + expect(getByText(/productId: dev.hyo.martie.premium/)).toBeTruthy(); + }); + + fireEvent.press(getByText('Trigger test notification')); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:8787/v1/webhooks/test-key', + expect.objectContaining({ + method: 'POST', + headers: {'content-type': 'application/json'}, + }), + ); + expect(getByText('Test notification accepted.')).toBeTruthy(); + }); + }); + it('fails explicitly when base64 encoding support is missing', () => { const globalWithBtoa = globalThis as {btoa?: (value: string) => string}; const originalBtoa = globalWithBtoa.btoa; diff --git a/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts new file mode 100644 index 00000000..74163c74 --- /dev/null +++ b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts @@ -0,0 +1,67 @@ +import type {Purchase} from 'react-native-iap'; +import {createIapkitVerificationPayload} from '../../src/utils/vegaRuntime'; + +describe('Vega runtime example helpers', () => { + it('uses Amazon receipt verification when purchase store is Amazon', () => { + const payload = createIapkitVerificationPayload( + { + id: 'receipt-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'receipt-1', + store: 'Amazon', + } as unknown as Purchase, + 'receipt-1', + 'test-api-key', + 'http://localhost:3100', + ); + + expect(payload).toMatchObject({ + apiKey: 'test-api-key', + baseUrl: 'http://localhost:3100', + amazon: { + receiptId: 'receipt-1', + sandbox: true, + }, + }); + }); + + it('uses Google verification when purchase store is Google', () => { + const payload = createIapkitVerificationPayload( + { + id: 'token-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'token-1', + store: 'google', + } as unknown as Purchase, + 'token-1', + 'test-api-key', + ); + + expect(payload).toMatchObject({ + apiKey: 'test-api-key', + google: { + purchaseToken: 'token-1', + }, + }); + }); + + it('uses Apple verification when purchase store is Apple', () => { + const payload = createIapkitVerificationPayload( + { + id: 'jws-1', + productId: 'dev.hyo.martie.monthly', + purchaseToken: 'jws-1', + store: 'apple', + } as unknown as Purchase, + 'jws-1', + 'test-api-key', + ); + + expect(payload).toMatchObject({ + apiKey: 'test-api-key', + apple: { + jws: 'jws-1', + }, + }); + }); +}); diff --git a/libraries/react-native-iap/example/amazon.config.json b/libraries/react-native-iap/example/amazon.config.json new file mode 100644 index 00000000..4f1837fa --- /dev/null +++ b/libraries/react-native-iap/example/amazon.config.json @@ -0,0 +1,3 @@ +{ + "debug.amazon.sandboxmode": "debug" +} diff --git a/libraries/react-native-iap/example/amazon.sdktester.json b/libraries/react-native-iap/example/amazon.sdktester.json new file mode 100644 index 00000000..43cd5e90 --- /dev/null +++ b/libraries/react-native-iap/example/amazon.sdktester.json @@ -0,0 +1,43 @@ +{ + "dev.hyo.martie.10bulbs": { + "itemType": "CONSUMABLE", + "price": 0.99, + "title": "10 Bulbs", + "description": "A small pack of bulbs for testing consumable purchases", + "smallIconUrl": "https://openiap.dev/img/logo.png" + }, + "dev.hyo.martie.30bulbs": { + "itemType": "CONSUMABLE", + "price": 1.99, + "title": "30 Bulbs", + "description": "A larger pack of bulbs for testing consumable purchases", + "smallIconUrl": "https://openiap.dev/img/logo.png" + }, + "dev.hyo.martie.certified": { + "itemType": "ENTITLED", + "price": 4.99, + "title": "Certified", + "description": "A non-consumable entitlement for OpenIAP example testing", + "smallIconUrl": "https://openiap.dev/img/logo.png" + }, + "dev.hyo.martie.premium": { + "itemType": "SUBSCRIPTION", + "price": 4.99, + "title": "Premium Monthly", + "description": "Monthly premium access for OpenIAP example testing", + "smallIconUrl": "https://openiap.dev/img/logo.png", + "subscriptionBase": "dev.hyo.martie.premium.base", + "subscriptionParent": "dev.hyo.martie.premium.parent", + "term": "Monthly" + }, + "dev.hyo.martie.premium_year": { + "itemType": "SUBSCRIPTION", + "price": 49.99, + "title": "Premium Yearly", + "description": "Yearly premium access for OpenIAP example testing", + "smallIconUrl": "https://openiap.dev/img/logo.png", + "subscriptionBase": "dev.hyo.martie.premium.base", + "subscriptionParent": "dev.hyo.martie.premium.parent", + "term": "Yearly" + } +} diff --git a/libraries/react-native-iap/example/android/app/build.gradle b/libraries/react-native-iap/example/android/app/build.gradle index a6b1b95e..c3bdd793 100644 --- a/libraries/react-native-iap/example/android/app/build.gradle +++ b/libraries/react-native-iap/example/android/app/build.gradle @@ -84,11 +84,19 @@ android { targetSdkVersion = rootProject.ext.targetSdkVersion 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 } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } packagingOptions { diff --git a/libraries/react-native-iap/example/android/gradle.properties b/libraries/react-native-iap/example/android/gradle.properties index d5e2f043..cd514221 100644 --- a/libraries/react-native-iap/example/android/gradle.properties +++ b/libraries/react-native-iap/example/android/gradle.properties @@ -47,3 +47,8 @@ edgeToEdgeEnabled=false # 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 diff --git a/libraries/react-native-iap/example/android/settings.gradle b/libraries/react-native-iap/example/android/settings.gradle index e844166f..1bb5d28a 100644 --- a/libraries/react-native-iap/example/android/settings.gradle +++ b/libraries/react-native-iap/example/android/settings.gradle @@ -1,4 +1,17 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { + includeBuild("../node_modules/@react-native/gradle-plugin") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + plugins { + id("com.android.library") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.2.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false + id("com.vanniktech.maven.publish") version "0.34.0" apply false + } +} plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> def command = ['node', '-e', "console.log(require('@react-native-community/cli').bin)"].execute(null, rootDir).text.trim() @@ -7,3 +20,9 @@ extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> rootProject.name = 'dev.hyo.martie' include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') + +def localOpenIapProject = new File(settingsDir, '../../../../packages/google/openiap') +if (localOpenIapProject.exists()) { + include ':openiap' + project(':openiap').projectDir = localOpenIapProject +} diff --git a/libraries/react-native-iap/example/jest.setup.js b/libraries/react-native-iap/example/jest.setup.js index 156ae13b..9a4309f3 100644 --- a/libraries/react-native-iap/example/jest.setup.js +++ b/libraries/react-native-iap/example/jest.setup.js @@ -6,6 +6,15 @@ global.__fbBatchedBridgeConfig = { localModulesConfig: [], }; +jest.mock( + '@env', + () => ({ + IAPKIT_API_KEY: '', + IAPKIT_BASE_URL: '', + }), + {virtual: true}, +); + // Mock react-native-nitro-modules jest.mock('react-native-nitro-modules', () => ({ NitroModules: { @@ -39,6 +48,16 @@ jest.mock('../src/index', () => { const mockFinishTransaction = jest.fn(() => Promise.resolve()); const mockGetActiveSubscriptions = jest.fn(() => Promise.resolve([])); const mockRequestPurchase = jest.fn(() => Promise.resolve()); + const mockVerifyPurchase = jest.fn(() => Promise.resolve({})); + const mockVerifyPurchaseWithProvider = jest.fn(() => + Promise.resolve({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'amazon', + }, + }), + ); const mockUseIAP = jest.fn(() => ({ connected: false, @@ -50,6 +69,8 @@ jest.mock('../src/index', () => { finishTransaction: mockFinishTransaction, getAvailablePurchases: mockGetAvailablePurchases, getActiveSubscriptions: mockGetActiveSubscriptions, + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, })); return { @@ -68,6 +89,8 @@ jest.mock('../src/index', () => { connectWebhookStream: jest.fn(() => ({ close: jest.fn(), })), + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, // Android specific acknowledgePurchaseAndroid: jest.fn(() => Promise.resolve(true)), @@ -231,6 +254,7 @@ console.error = (...args) => { if ( args[0]?.includes?.('Warning: ReactTestRenderer') || args[0]?.includes?.('Warning: An update to') || + args[0]?.includes?.('An update to') || args[0]?.includes?.('Warning: You called act') ) { return; diff --git a/libraries/react-native-iap/example/manifest.toml b/libraries/react-native-iap/example/manifest.toml new file mode 100644 index 00000000..3c911981 --- /dev/null +++ b/libraries/react-native-iap/example/manifest.toml @@ -0,0 +1,36 @@ +schema-version = 1 + +[package] +id = "dev.hyo.openiap.rniap.example" +title = "React Native IAP Example" +version = "1.0.0" + +[components] +[[components.interactive]] +id = "dev.hyo.openiap.rniap.example.main" +runtime-module = "/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0" +launch-type = "singleton" +categories = ["com.amazon.category.main"] + +[wants] +[[wants.service]] +id = "com.amazon.inputmethod.service" + +[[wants.service]] +id = "com.amazon.network.service" + +[[wants.service]] +id = "com.amazon.iap.core.service" + +[[wants.service]] +id = "com.amazon.iap.tester.service" + +[[wants.module]] +id = "/com.amazon.iap.core@IIAPCoreUI" + +[[wants.module]] +id = "/com.amazonappstore.iap.tester@IIAPTesterUI" + +[needs] +[[needs.module]] +id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" diff --git a/libraries/react-native-iap/example/package.json b/libraries/react-native-iap/example/package.json index 24adcc87..89c10dac 100644 --- a/libraries/react-native-iap/example/package.json +++ b/libraries/react-native-iap/example/package.json @@ -5,6 +5,9 @@ "scripts": { "android": "RN_IAP_DEV_MODE=true react-native run-android", "ios": "RN_IAP_DEV_MODE=true react-native run-ios", + "build:vega:debug": "node scripts/build-vega-example.mjs Debug", + "build:vega:release": "node scripts/build-vega-example.mjs Release", + "run:vega:firetv": "node scripts/run-vega-firetv.mjs", "lint": "eslint .", "start": "RN_IAP_DEV_MODE=true react-native start", "test": "jest", diff --git a/libraries/react-native-iap/example/screens/AllProducts.tsx b/libraries/react-native-iap/example/screens/AllProducts.tsx index e93d0a8e..66ff5f02 100644 --- a/libraries/react-native-iap/example/screens/AllProducts.tsx +++ b/libraries/react-native-iap/example/screens/AllProducts.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState, useMemo} from 'react'; +import {useCallback, useEffect, useMemo, useState} from 'react'; import { View, Text, @@ -16,6 +16,7 @@ import { CONSUMABLE_PRODUCT_IDS, NON_CONSUMABLE_PRODUCT_IDS, } from '../src/utils/constants'; +import {getErrorMessage} from '../src/utils/errorUtils'; import type { Product, ProductAndroid, @@ -25,13 +26,11 @@ import type { } from 'react-native-iap'; import AndroidOneTimeOfferDetails from '../src/components/AndroidOneTimeOfferDetails'; -const ALL_PRODUCT_IDS = [...PRODUCT_IDS, ...SUBSCRIPTION_PRODUCT_IDS]; - /** * All Products Example - Show All Products and Subscriptions * * Demonstrates fetching all products (both in-app and subscriptions): - * - Uses fetchProducts with 'all' type to get everything + * - Fetches in-app products and subscriptions separately * - Displays products and subscriptions as they come from the API * - Single view for all product types * @@ -72,28 +71,39 @@ function AllProducts() { Product | ProductSubscription | null >(null); const [modalVisible, setModalVisible] = useState(false); + const [loadError, setLoadError] = useState(null); + + const handleLoadError = useCallback((error: unknown) => { + const message = getErrorMessage(error); + console.log('[AllProducts] fetchProducts error:', message); + setLoadError(message); + }, []); - const {connected, products, subscriptions, fetchProducts} = useIAP(); + const {connected, products, subscriptions, fetchProducts} = useIAP({ + onError: handleLoadError, + }); useEffect(() => { console.log('[AllProducts] useEffect - connected:', connected); if (connected) { console.log( - '[AllProducts] Fetching all products with SKUs:', - ALL_PRODUCT_IDS, + '[AllProducts] Fetching product groups with SKUs:', + PRODUCT_IDS, + SUBSCRIPTION_PRODUCT_IDS, ); + setLoadError(null); - // Fetch all products with type 'all' - fetchProducts({skus: ALL_PRODUCT_IDS, type: 'all'}) + Promise.all([ + fetchProducts({skus: PRODUCT_IDS, type: 'in-app'}), + fetchProducts({skus: SUBSCRIPTION_PRODUCT_IDS, type: 'subs'}), + ]) .then(() => { console.log('[AllProducts] fetchProducts completed'); }) - .catch((error) => { - console.error('[AllProducts] fetchProducts error:', error); - }); + .catch(handleLoadError); } - }, [connected, fetchProducts]); + }, [connected, fetchProducts, handleLoadError]); // Prepare sections for SectionList const sections = useMemo(() => { @@ -264,6 +274,12 @@ function AllProducts() { {connected ? '✅ Connected' : '❌ Disconnected'} + {loadError ? ( + + Product loading failed + {loadError} + + ) : null} ); @@ -697,6 +713,26 @@ const styles = StyleSheet.create({ color: '#666', textAlign: 'center', }, + errorBox: { + backgroundColor: '#FFF3E0', + borderColor: '#FF9800', + borderRadius: 8, + borderWidth: 1, + marginHorizontal: 15, + marginBottom: 15, + padding: 12, + }, + errorTitle: { + color: '#E65100', + fontSize: 14, + fontWeight: '700', + marginBottom: 4, + }, + errorText: { + color: '#5D4037', + fontSize: 13, + lineHeight: 18, + }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', diff --git a/libraries/react-native-iap/example/screens/AlternativeBilling.tsx b/libraries/react-native-iap/example/screens/AlternativeBilling.tsx index 3b1b1321..c2bed056 100644 --- a/libraries/react-native-iap/example/screens/AlternativeBilling.tsx +++ b/libraries/react-native-iap/example/screens/AlternativeBilling.tsx @@ -75,6 +75,8 @@ import {CONSUMABLE_PRODUCT_IDS} from '../src/utils/constants'; // Android Billing Mode types - all unified under BillingProgramAndroid type AndroidBillingMode = 'billing-programs' | 'external-payments'; +const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; + function AlternativeBillingScreen() { const [externalUrl, setExternalUrl] = useState('https://openiap.dev'); const [selectedProduct, setSelectedProduct] = useState(null); @@ -91,6 +93,7 @@ function AlternativeBillingScreen() { const [lastPurchase, setLastPurchase] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); + const isVega = isVegaOS(); // Initialize with billing program config (recommended over deprecated alternativeBillingModeAndroid) const {connected, products, fetchProducts, finishTransaction} = useIAP({ @@ -122,13 +125,13 @@ function AlternativeBillingScreen() { }); console.log('Transaction finished'); } catch (error) { - console.warn('Failed to finish transaction:', error); + console.log('Failed to finish transaction:', error); } Alert.alert('Success', 'Purchase completed successfully!'); }, onPurchaseError: (error: PurchaseError) => { - console.error('Purchase failed:', error); + console.log('Purchase failed:', error); setIsProcessing(false); setPurchaseResult(`❌ Purchase failed: ${error.message}`); @@ -140,19 +143,19 @@ function AlternativeBillingScreen() { // Load products when connected useEffect(() => { - if (connected) { + if (connected && !isVega) { fetchProducts({skus: CONSUMABLE_PRODUCT_IDS, type: 'in-app'}).catch( (error) => { - console.error('Failed to load products:', error); + console.log('Failed to load products:', error); Alert.alert('Error', 'Failed to load products'); }, ); } - }, [connected, fetchProducts]); + }, [connected, fetchProducts, isVega]); // Set up External Payments listener (Android 8.3.0+ - Japan only) useEffect(() => { - if (Platform.OS !== 'android') return; + if (Platform.OS !== 'android' || isVega) return; const subscription = developerProvidedBillingListenerAndroid( (details: DeveloperProvidedBillingDetailsAndroid) => { @@ -180,7 +183,7 @@ function AlternativeBillingScreen() { return () => { subscription.remove(); }; - }, []); + }, [isVega]); // Reconnect with new billing program const reconnectWithBillingProgram = useCallback( @@ -209,7 +212,7 @@ function AlternativeBillingScreen() { // Reload products await fetchProducts({skus: CONSUMABLE_PRODUCT_IDS, type: 'in-app'}); } catch (error: any) { - console.error('Reconnection error:', error); + console.log('Reconnection error:', error); setPurchaseResult(`❌ Reconnection failed: ${error.message}`); } finally { setIsReconnecting(false); @@ -251,7 +254,7 @@ function AlternativeBillingScreen() { ); } } catch (error: any) { - console.error('[iOS] Alternative billing error:', error); + console.log('[iOS] Alternative billing error:', error); setPurchaseResult(`❌ Error: ${error.message}`); Alert.alert('Error', error.message); } finally { @@ -319,7 +322,7 @@ function AlternativeBillingScreen() { Alert.alert('Error', 'Failed to launch external link'); } } catch (error: any) { - console.error('[Android] Billing Programs error:', error); + console.log('[Android] Billing Programs error:', error); setPurchaseResult(`❌ Error: ${error.message}`); Alert.alert('Error', error.message); } finally { @@ -359,7 +362,7 @@ function AlternativeBillingScreen() { `🔄 External Payments dialog shown\n\nProduct: ${product.id}\n\nWaiting for user choice:\n- Google Play → purchaseUpdatedListener\n- Developer billing → developerProvidedBillingListener`, ); } catch (error: any) { - console.error('[Android] External Payments error:', error); + console.log('[Android] External Payments error:', error); setIsProcessing(false); if (error.code !== 'user-cancelled') { @@ -376,6 +379,13 @@ function AlternativeBillingScreen() { // Handle purchase based on platform and mode const handlePurchase = useCallback( (product: Product) => { + if (isVega) { + setPurchaseResult( + 'Alternative billing is not supported on Amazon Vega. Use the standard Amazon IAP Purchase Flow or Subscription Flow screens instead.', + ); + return; + } + if (Platform.OS === 'ios') { handleIosAlternativeBillingPurchase(product); } else if (Platform.OS === 'android') { @@ -391,6 +401,7 @@ function AlternativeBillingScreen() { handleIosAlternativeBillingPurchase, handleAndroidBillingPrograms, handleAndroidExternalPayments, + isVega, ], ); @@ -403,9 +414,11 @@ function AlternativeBillingScreen() { Alternative Billing - {Platform.OS === 'ios' - ? 'External purchase links (iOS 16.0+)' - : 'Google Play alternative billing'} + {isVega + ? 'Not supported on Amazon Vega' + : Platform.OS === 'ios' + ? 'External purchase links (iOS 16.0+)' + : 'Google Play alternative billing'} @@ -413,7 +426,20 @@ function AlternativeBillingScreen() { {/* Info Card */} ℹ️ How It Works - {Platform.OS === 'ios' ? ( + {isVega ? ( + <> + + • Vega OS uses Amazon Appstore IAP through the Vega JavaScript + runtime{'\n'}• Google Play Billing Programs and iOS external + purchase links do not apply{'\n'}• Test Amazon IAP in the + Purchase Flow and Subscription Flow screens + + + ⚠️ Alternative billing APIs are intentionally unsupported on + Amazon Vega. + + + ) : Platform.OS === 'ios' ? ( <> • Enter your external purchase URL{'\n'}• Tap Purchase on any @@ -527,7 +553,11 @@ function AlternativeBillingScreen() { > {connected ? '✅ Connected' : '❌ Disconnected'} - {Platform.OS === 'android' ? ( + {isVega ? ( + + Current mode: Amazon Vega standard IAP + + ) : Platform.OS === 'android' ? ( Current mode:{' '} {androidBillingMode === 'billing-programs' @@ -602,18 +632,20 @@ function AlternativeBillingScreen() { handlePurchase(selectedProduct)} - disabled={isProcessing || !connected} + disabled={isProcessing || !connected || isVega} > {isProcessing ? 'Processing...' - : Platform.OS === 'ios' - ? '🛒 Buy (External URL)' - : billingProgram === 'external-offer' - ? '🛒 Buy (External Offer)' - : billingProgram === 'user-choice-billing' - ? '🛒 Buy (User Choice)' - : `🛒 Buy (${billingProgram})`} + : isVega + ? 'Not supported on Vega' + : Platform.OS === 'ios' + ? '🛒 Buy (External URL)' + : billingProgram === 'external-offer' + ? '🛒 Buy (External Offer)' + : billingProgram === 'user-choice-billing' + ? '🛒 Buy (User Choice)' + : `🛒 Buy (${billingProgram})`} @@ -644,7 +676,9 @@ function AlternativeBillingScreen() { External Payments Token (Japan) - Token: {externalPaymentsToken} + + Token: {externalPaymentsToken} + ⚠️ Report this token to Google Play within 24 hours{'\n'} ℹ️ Process external payment through your system diff --git a/libraries/react-native-iap/example/screens/AvailablePurchases.tsx b/libraries/react-native-iap/example/screens/AvailablePurchases.tsx index 6ea2eb3b..c666405d 100644 --- a/libraries/react-native-iap/example/screens/AvailablePurchases.tsx +++ b/libraries/react-native-iap/example/screens/AvailablePurchases.tsx @@ -18,9 +18,15 @@ const subscriptionIds = [ 'dev.hyo.martie.premium', // Same as subscription-flow ]; +const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; + export default function AvailablePurchases() { const [loading, setLoading] = useState(false); const [isCheckingStatus, setIsCheckingStatus] = useState(false); + const [subscriptionLinkMessage, setSubscriptionLinkMessage] = useState< + string | null + >(null); + const isVega = isVegaOS(); // Use global modal context const {showData} = useDataModal(); @@ -55,7 +61,7 @@ export default function AvailablePurchases() { }, 1000); }, onPurchaseError: (error: PurchaseError) => { - console.error('[AVAILABLE-PURCHASES] Purchase failed:', error); + console.log('[AVAILABLE-PURCHASES] Purchase failed:', error); Alert.alert('Purchase Failed', error.message); }, }); @@ -79,11 +85,11 @@ export default function AvailablePurchases() { 'items', ); } catch (error) { - console.error( + console.log( '[AVAILABLE-PURCHASES] Error checking subscription status:', error, ); - console.warn( + console.log( '[AVAILABLE-PURCHASES] Subscription status check failed, but existing state preserved', ); } finally { @@ -100,7 +106,7 @@ export default function AvailablePurchases() { await getAvailablePurchases(); console.log('Available purchases request sent'); } catch (error) { - console.error('Error getting available purchases:', error); + console.log('Error getting available purchases:', error); Alert.alert('Error', 'Failed to get available purchases'); } finally { setLoading(false); @@ -113,8 +119,12 @@ export default function AvailablePurchases() { console.log( '[AVAILABLE-PURCHASES] Connected to store, loading subscription products...', ); - // Request products first - this is event-based, not promise-based - fetchProducts({skus: subscriptionIds, type: 'subs'}); + fetchProducts({skus: subscriptionIds, type: 'subs'}).catch((error) => { + console.log( + '[AVAILABLE-PURCHASES] Failed to load subscription products:', + error, + ); + }); console.log( '[AVAILABLE-PURCHASES] Product loading request sent - waiting for results...', ); @@ -122,7 +132,7 @@ export default function AvailablePurchases() { // Then load available purchases console.log('[AVAILABLE-PURCHASES] Loading available purchases...'); getAvailablePurchases().catch((error) => { - console.warn( + console.log( '[AVAILABLE-PURCHASES] Failed to load available purchases:', error, ); @@ -170,7 +180,20 @@ export default function AvailablePurchases() { }, [subscriptions]); const openManageSubscriptions = () => { - deepLinkToSubscriptions().catch(() => {}); + if (isVega) { + setSubscriptionLinkMessage( + 'Subscription management deep links are not exposed through the Amazon Vega OpenIAP adapter. Use this screen to inspect active subscriptions and purchase history.', + ); + return; + } + + deepLinkToSubscriptions().catch((error) => { + setSubscriptionLinkMessage( + error instanceof Error + ? error.message + : 'Failed to open subscription management.', + ); + }); }; return ( @@ -251,6 +274,9 @@ export default function AvailablePurchases() { > 👤 Manage Subscriptions + {subscriptionLinkMessage ? ( + {subscriptionLinkMessage} + ) : null} )} @@ -520,4 +546,10 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, + helperText: { + color: '#666', + fontSize: 13, + lineHeight: 18, + marginTop: 10, + }, }); diff --git a/libraries/react-native-iap/example/screens/Home.tsx b/libraries/react-native-iap/example/screens/Home.tsx index 9c9a47ff..c2b2a0ef 100644 --- a/libraries/react-native-iap/example/screens/Home.tsx +++ b/libraries/react-native-iap/example/screens/Home.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useState} from 'react'; import { View, Text, @@ -19,6 +19,7 @@ type Props = { }; const Home: React.FC = ({navigation}) => { + const [focusedIndex, setFocusedIndex] = useState(0); const menuItems = [ { title: 'All Products', @@ -75,7 +76,14 @@ const Home: React.FC = ({navigation}) => { {menuItems.map((item, index) => ( setFocusedIndex(index)} onPress={() => { if (item.enabled) { navigation.navigate(item.route as keyof RootStackParamList); @@ -146,6 +154,8 @@ const styles = StyleSheet.create({ }, menuItem: { backgroundColor: '#fff', + borderColor: 'transparent', + borderWidth: 2, borderRadius: 12, padding: 20, marginBottom: 12, @@ -161,6 +171,9 @@ const styles = StyleSheet.create({ shadowRadius: 3.84, elevation: 5, }, + menuItemFocused: { + borderColor: '#2563eb', + }, menuItemDisabled: { opacity: 0.6, }, diff --git a/libraries/react-native-iap/example/screens/OfferCode.tsx b/libraries/react-native-iap/example/screens/OfferCode.tsx index 70a170f0..a5b1dd7a 100644 --- a/libraries/react-native-iap/example/screens/OfferCode.tsx +++ b/libraries/react-native-iap/example/screens/OfferCode.tsx @@ -18,8 +18,23 @@ import {presentCodeRedemptionSheetIOS, useIAP} from 'react-native-iap'; * functionality for both iOS and Android platforms. */ +const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; + // Platform-specific content helpers const getPlatformContent = () => { + if (isVegaOS()) { + return { + buttonText: 'Amazon Vega IAP', + buttonSubtext: 'Offer code redemption is unavailable', + howItWorks: + '• Vega OS uses Amazon App Tester or Amazon Appstore catalog data\n• iOS offer codes and Google Play promo codes do not apply\n• Use the Purchase Flow or Subscription Flow screens to test Amazon IAP', + platformNote: + 'Vega OS does not expose an OpenIAP offer-code redemption API.', + testingInfo: + '• Configure amazon.sdktester.json for sandbox products\n• Enable sandbox mode with amazon.config.json\n• Test purchases through the Amazon App Tester flow', + }; + } + const isIOS = Platform.OS === 'ios'; return { buttonText: isIOS ? '🎁 Redeem Offer Code' : '🎁 Open Play Store', @@ -39,10 +54,19 @@ const getPlatformContent = () => { export default function OfferCodeScreen() { const {connected} = useIAP(); const [isRedeeming, setIsRedeeming] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); const platformContent = getPlatformContent(); const isIOS = Platform.OS === 'ios'; + const isVega = isVegaOS(); const handleRedeemCode = async () => { + if (isVega) { + setStatusMessage( + 'Offer code redemption is not supported on Amazon Vega. Use Amazon App Tester catalog entries and the standard purchase or subscription flows instead.', + ); + return; + } + if (!connected) { Alert.alert('Not Connected', 'Please wait for store connection'); return; @@ -73,7 +97,7 @@ export default function OfferCodeScreen() { ); } } catch (error) { - console.error('Error redeeming code:', error); + console.log('Error redeeming code:', error); Alert.alert( 'Error', `Failed to redeem code: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -118,11 +142,17 @@ export default function OfferCodeScreen() { - Platform: {isIOS ? 'iOS' : 'Android'} + Platform: {isVega ? 'Vega OS' : isIOS ? 'iOS' : 'Android'} {platformContent.platformNote} + {statusMessage ? ( + + {statusMessage} + + ) : null} + Testing Offer Codes {platformContent.testingInfo} @@ -300,6 +330,19 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#555', }, + statusMessageBox: { + backgroundColor: '#fff7ed', + borderColor: '#fed7aa', + borderRadius: 12, + borderWidth: 1, + marginBottom: 20, + padding: 16, + }, + statusMessageText: { + color: '#9a3412', + fontSize: 14, + lineHeight: 20, + }, androidNote: { backgroundColor: '#fff3cd', padding: 16, diff --git a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx index 9f21568d..77e887df 100644 --- a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx +++ b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx @@ -17,7 +17,7 @@ import { getStorefront, ErrorCode, } from 'react-native-iap'; -import {IAPKIT_API_KEY} from '@env'; +import {IAPKIT_API_KEY, IAPKIT_BASE_URL} from '@env'; import Loading from '../src/components/Loading'; import { CONSUMABLE_PRODUCT_IDS, @@ -26,9 +26,15 @@ import { } from '../src/utils/constants'; import {getErrorMessage} from '../src/utils/errorUtils'; import { + getDefaultVerificationMethod, useVerificationMethod, type VerificationMethod, } from '../src/hooks/useVerificationMethod'; +import { + createIapkitVerificationPayload, + getPurchaseCleanupKey, + showNativeAlert, +} from '../src/utils/vegaRuntime'; import type { Product, Purchase, @@ -40,6 +46,13 @@ import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; const CONSUMABLE_PRODUCT_ID_SET = new Set(CONSUMABLE_PRODUCT_IDS); const NON_CONSUMABLE_PRODUCT_ID_SET = new Set(NON_CONSUMABLE_PRODUCT_IDS); +function isPurchaseFlowProduct(productId: string): boolean { + return ( + CONSUMABLE_PRODUCT_ID_SET.has(productId) || + NON_CONSUMABLE_PRODUCT_ID_SET.has(productId) + ); +} + type PurchaseFlowProps = { connected: boolean; products: Product[]; @@ -127,7 +140,7 @@ function PurchaseFlow({ Alert.alert('App Transaction', 'No app transaction found'); } } catch (error) { - console.error('Failed to get app transaction:', error); + console.log('Failed to get app transaction:', error); Alert.alert('Error', 'Failed to get app transaction'); } }; @@ -217,7 +230,7 @@ function PurchaseFlow({ : 'Loading products...'} - {visibleProducts.map((product) => ( + {visibleProducts.map((product, index) => ( {product.title} @@ -244,6 +257,8 @@ function PurchaseFlow({ handleShowDetails(product)} > @@ -576,7 +592,7 @@ function PurchaseFlowContainer() { verificationMethod, verificationMethodRef, showVerificationMethodSelector, - } = useVerificationMethod('ignore'); + } = useVerificationMethod(getDefaultVerificationMethod(IAPKIT_API_KEY)); // ────────────────────────────────────────────────────────────────────────── // Step 1: INIT CONNECTION @@ -589,8 +605,10 @@ function PurchaseFlowContainer() { const { connected, products, + availablePurchases, fetchProducts, finishTransaction, + getAvailablePurchases, verifyPurchase, verifyPurchaseWithProvider, } = useIAP({ @@ -605,27 +623,27 @@ function PurchaseFlowContainer() { }; console.log('Purchase successful:', masked); console.log('[PurchaseFlow] purchaseState:', purchase.purchaseState); + const productId = purchase.productId ?? ''; + if (!isPurchaseFlowProduct(productId)) { + console.log('[PurchaseFlow] ignoring non-purchase-flow product:', { + productId, + }); + return; + } + setLastPurchase(purchase); setIsProcessing(false); setPurchaseResult( - `Purchase completed successfully (state: ${purchase.purchaseState}).`, + `Purchase received (state: ${purchase.purchaseState}). Finishing transaction...`, ); - const productId = purchase.productId ?? ''; const isConsumablePurchase = CONSUMABLE_PRODUCT_ID_SET.has(productId); - if (!isConsumablePurchase && productId) { - if (NON_CONSUMABLE_PRODUCT_ID_SET.has(productId)) { - console.log( - '[PurchaseFlow] Non-consumable purchase recorded:', - productId, - ); - } else { - console.warn( - '[PurchaseFlow] Purchase for product not listed in constants:', - productId, - ); - } + if (!isConsumablePurchase) { + console.log( + '[PurchaseFlow] Non-consumable purchase recorded:', + productId, + ); } // ────────────────────────────────────────────────────────────────────── @@ -695,7 +713,7 @@ function PurchaseFlowContainer() { const jwsOrToken = purchase.purchaseToken ?? ''; if (!jwsOrToken) { - console.warn( + console.log( '[PurchaseFlow] No purchaseToken/JWS available for verification', ); throw new Error( @@ -703,17 +721,19 @@ function PurchaseFlowContainer() { ); } + const iapkitPayload = createIapkitVerificationPayload( + purchase, + jwsOrToken, + apiKey, + IAPKIT_BASE_URL, + ); const verifyRequest: VerifyPurchaseWithProviderProps = { provider: 'iapkit', - iapkit: { - apiKey, - apple: { - jws: jwsOrToken, - }, - google: { - purchaseToken: jwsOrToken, - }, - }, + iapkit: iapkitPayload, + }; + const iapkitLogPayload = { + ...iapkitPayload, + apiKey: '***hidden***', }; console.log( @@ -721,16 +741,7 @@ function PurchaseFlowContainer() { JSON.stringify( { provider: verifyRequest.provider, - iapkit: { - apiKey: '***hidden***', - ...(Platform.OS === 'ios' - ? {apple: {jws: jwsOrToken}} - : { - google: { - purchaseToken: jwsOrToken, - }, - }), - }, + iapkit: iapkitLogPayload, }, null, 2, @@ -745,7 +756,7 @@ function PurchaseFlowContainer() { const statusEmoji = result.iapkit.isValid ? '✅' : '⚠️'; const stateText = result.iapkit.state || 'unknown'; - Alert.alert( + showNativeAlert( `${statusEmoji} IAPKit Verification`, `Valid: ${result.iapkit.isValid}\nState: ${stateText}\nStore: ${ result.iapkit.store || 'unknown' @@ -755,12 +766,12 @@ function PurchaseFlowContainer() { const errorMessages = result.errors .map((e) => `${e.code ? `[${e.code}] ` : ''}${e.message}`) .join('\n'); - Alert.alert('⚠️ IAPKit Verification Error', errorMessages); + showNativeAlert('⚠️ IAPKit Verification Error', errorMessages); } } } catch (error) { - console.warn('[PurchaseFlow] Verification failed:', error); - Alert.alert( + console.log('[PurchaseFlow] Verification failed:', error); + showNativeAlert( 'Verification Failed', `Purchase verification failed: ${getErrorMessage(error)}`, ); @@ -790,23 +801,29 @@ function PurchaseFlowContainer() { purchase, isConsumable: isConsumablePurchase, }); + setPurchaseResult( + `Purchase completed and finished successfully (state: ${purchase.purchaseState}).`, + ); + showNativeAlert('Success', 'Purchase completed successfully!'); } catch (error) { - console.warn('[PurchaseFlow] finishTransaction failed:', error); + console.log('[PurchaseFlow] finishTransaction failed:', error); + const message = getErrorMessage(error); + setPurchaseResult( + `Purchase completed, but finishTransaction failed: ${message}`, + ); + showNativeAlert('Finish Transaction Failed', message); } - - Alert.alert('Success', 'Purchase completed successfully!'); }, // ──────────────────────────────────────────────────────────────────────── // Step 2b: Purchase Error Handler // ──────────────────────────────────────────────────────────────────────── onPurchaseError: (error: PurchaseError) => { - console.error('Purchase failed:', error); - console.error('Error code:', error.code); - console.error( - 'Is user cancelled:', - error.code === ErrorCode.UserCancelled, - ); + console.log('Purchase failed:', { + code: error.code, + message: error.message, + userCancelled: error.code === ErrorCode.UserCancelled, + }); setIsProcessing(false); @@ -826,6 +843,7 @@ function PurchaseFlowContainer() { // Helpers // ────────────────────────────────────────────────────────────────────────── const didFetchRef = useRef(false); + const cleanupPurchaseKeysRef = useRef(new Set()); const fetchStorefront = useCallback(async () => { setFetchingStorefront(true); @@ -833,7 +851,7 @@ function PurchaseFlowContainer() { const code = await getStorefront(); setStorefront(code?.trim() ? code : null); } catch (error) { - console.warn('[PurchaseFlow] getStorefront failed:', error); + console.log('[PurchaseFlow] getStorefront failed:', error); setStorefront(null); } finally { setFetchingStorefront(false); @@ -854,16 +872,65 @@ function PurchaseFlowContainer() { console.log('[PurchaseFlow] fetchProducts completed'); }) .catch((error) => { - console.error('[PurchaseFlow] fetchProducts error:', error); + const message = getErrorMessage(error); + console.log('[PurchaseFlow] fetchProducts error:', message); + setPurchaseResult(`Product loading failed: ${message}`); + }); + + getAvailablePurchases() + .then(() => { + console.log('[PurchaseFlow] getAvailablePurchases completed'); + }) + .catch((error) => { + console.log('[PurchaseFlow] getAvailablePurchases error:', error); }); void fetchStorefront(); } else if (!connected) { didFetchRef.current = false; + cleanupPurchaseKeysRef.current.clear(); console.log('[PurchaseFlow] Not fetching products - not connected'); setStorefront(null); } - }, [connected, fetchProducts, fetchStorefront]); + }, [connected, fetchProducts, fetchStorefront, getAvailablePurchases]); + + useEffect(() => { + if (!connected || availablePurchases.length === 0) return; + + for (const purchase of availablePurchases) { + const productId = purchase.productId ?? ''; + if (!isPurchaseFlowProduct(productId)) { + console.log( + '[PurchaseFlow] skipping cleanup for non-purchase-flow product:', + {productId}, + ); + continue; + } + + const cleanupKey = getPurchaseCleanupKey(purchase); + if (cleanupPurchaseKeysRef.current.has(cleanupKey)) continue; + cleanupPurchaseKeysRef.current.add(cleanupKey); + + const isConsumablePurchase = CONSUMABLE_PRODUCT_ID_SET.has(productId); + finishTransaction({ + purchase, + isConsumable: isConsumablePurchase, + }) + .then(() => { + console.log('[PurchaseFlow] cleaned up available purchase:', { + productId, + isConsumable: isConsumablePurchase, + }); + }) + .catch((error) => { + cleanupPurchaseKeysRef.current.delete(cleanupKey); + console.log( + '[PurchaseFlow] available purchase cleanup failed:', + error, + ); + }); + } + }, [availablePurchases, connected, finishTransaction]); // ────────────────────────────────────────────────────────────────────────── // Step 3: REQUEST PURCHASE @@ -888,13 +955,6 @@ function PurchaseFlowContainer() { setIsProcessing(true); setPurchaseResult('Processing purchase...'); - if (typeof requestPurchase !== 'function') { - console.warn('[PurchaseFlow] requestPurchase missing (test/mock env)'); - setIsProcessing(false); - setPurchaseResult('Cannot start purchase in test/mock environment.'); - return; - } - // Using Option C: Cross-platform request void requestPurchase({ request: { @@ -907,6 +967,18 @@ function PurchaseFlowContainer() { }, }, type: 'in-app', + }).catch((err: PurchaseError) => { + console.log('requestPurchase failed:', { + code: err.code, + message: err.message, + }); + setIsProcessing(false); + if (err.code === ErrorCode.UserCancelled) { + setPurchaseResult('Purchase cancelled by user'); + return; + } + + setPurchaseResult(`Purchase failed: ${err.message}`); }); }, []); diff --git a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx index 7bfcfec2..2250a092 100644 --- a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx +++ b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx @@ -28,11 +28,17 @@ import Loading from '../src/components/Loading'; import {SUBSCRIPTION_PRODUCT_IDS} from '../src/utils/constants'; import {getErrorMessage} from '../src/utils/errorUtils'; import { + getDefaultVerificationMethod, useVerificationMethod, type VerificationMethod, } from '../src/hooks/useVerificationMethod'; +import { + createIapkitVerificationPayload, + getPurchaseCleanupKey, + showNativeAlert, +} from '../src/utils/vegaRuntime'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; -import {IAPKIT_API_KEY} from '@env'; +import {IAPKIT_API_KEY, IAPKIT_BASE_URL} from '@env'; type ExtendedPurchase = Purchase & { purchaseTokenAndroid?: string; @@ -43,6 +49,14 @@ type ExtendedPurchase = Purchase & { offerToken?: string; }; +function isSubscriptionFlowProduct(productId: string): boolean { + return SUBSCRIPTION_PRODUCT_IDS.some( + (subscriptionId) => + productId === subscriptionId || + productId.startsWith(`${subscriptionId}.`), + ); +} + // 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: @@ -443,7 +457,7 @@ function SubscriptionFlow({ }, type: 'subs', }).catch((err: PurchaseError) => { - console.error('Plan change failed:', { + console.log('Plan change failed:', { code: err.code, message: err.message, }); @@ -1392,7 +1406,7 @@ function SubscriptionFlow({ {subscriptions.length > 0 ? ( - subscriptions.map((subscription) => { + subscriptions.map((subscription, index) => { const introOffer = renderIntroductoryOffer(subscription); const periodLabel = renderSubscriptionPeriod(subscription); const priceLabel = renderSubscriptionPrice(subscription); @@ -1410,6 +1424,7 @@ function SubscriptionFlow({ handleSubscriptionPress(subscription)} > @@ -1429,6 +1444,8 @@ function SubscriptionFlow({ ) : null} ()); // ────────────────────────────────────────────────────────────────────────── // STEP 1: INIT CONNECTION + SUBSCRIBE TO EVENTS @@ -1637,9 +1655,11 @@ function SubscriptionFlowContainer() { const { connected, subscriptions, + availablePurchases, activeSubscriptions, fetchProducts, finishTransaction, + getAvailablePurchases, getActiveSubscriptions, verifyPurchase, verifyPurchaseWithProvider, @@ -1651,7 +1671,23 @@ function SubscriptionFlowContainer() { // iOS: Check transactionState (purchased/pending/failed/deferred) // Android: Check purchaseState (0=pending, 1=purchased, 2=failed) onPurchaseSuccess: async (purchase: Purchase) => { - console.log('Purchase successful:', purchase); + const { + purchaseToken: tokenToMask, + purchaseTokenAndroid: androidTokenToMask, + ...purchaseRest + } = purchase as ExtendedPurchase; + console.log('Purchase successful:', { + ...purchaseRest, + ...(tokenToMask ? {purchaseToken: 'hidden'} : {}), + ...(androidTokenToMask ? {purchaseTokenAndroid: 'hidden'} : {}), + }); + const productId = purchase.productId ?? ''; + if (!isSubscriptionFlowProduct(productId)) { + console.log('[SubscriptionFlow] ignoring non-subscription product:', { + productId, + }); + return; + } // Try to detect which plan was purchased if (Platform.OS === 'ios') { @@ -1698,14 +1734,12 @@ function SubscriptionFlowContainer() { setIsProcessing(false); setPurchaseResult( - `✅ Subscription activated\n` + + `Subscription received; finishing transaction...\n` + `Product: ${purchase.productId}\n` + `Transaction ID: ${purchase.id}\n` + `Date: ${new Date(purchase.transactionDate).toLocaleDateString()}`, ); - const productId = purchase.productId ?? ''; - // ────────────────────────────────────────────────────────────────────── // STEP 3: VERIFY PURCHASE // ────────────────────────────────────────────────────────────────────── @@ -1770,7 +1804,7 @@ function SubscriptionFlowContainer() { const jwsOrToken = purchase.purchaseToken ?? ''; if (!jwsOrToken) { - console.warn( + console.log( '[SubscriptionFlow] No purchaseToken/JWS available for verification', ); throw new Error( @@ -1778,17 +1812,19 @@ function SubscriptionFlowContainer() { ); } + const iapkitPayload = createIapkitVerificationPayload( + purchase, + jwsOrToken, + apiKey, + IAPKIT_BASE_URL, + ); const verifyRequest: VerifyPurchaseWithProviderProps = { provider: 'iapkit', - iapkit: { - apiKey, - apple: { - jws: jwsOrToken, - }, - google: { - purchaseToken: jwsOrToken, - }, - }, + iapkit: iapkitPayload, + }; + const iapkitLogPayload = { + ...iapkitPayload, + apiKey: '***hidden***', }; console.log( @@ -1796,16 +1832,7 @@ function SubscriptionFlowContainer() { JSON.stringify( { provider: verifyRequest.provider, - iapkit: { - apiKey: '***hidden***', - ...(Platform.OS === 'ios' - ? {apple: {jws: jwsOrToken}} - : { - google: { - purchaseToken: jwsOrToken, - }, - }), - }, + iapkit: iapkitLogPayload, }, null, 2, @@ -1823,7 +1850,7 @@ function SubscriptionFlowContainer() { const statusEmoji = result.iapkit.isValid ? '✅' : '⚠️'; const stateText = result.iapkit.state || 'unknown'; - Alert.alert( + showNativeAlert( `${statusEmoji} IAPKit Verification`, `Valid: ${result.iapkit.isValid}\nState: ${stateText}\nStore: ${ result.iapkit.store || 'unknown' @@ -1833,15 +1860,15 @@ function SubscriptionFlowContainer() { const errorMessages = result.errors .map((e) => `${e.code ? `[${e.code}] ` : ''}${e.message}`) .join('\n'); - Alert.alert('⚠️ IAPKit Verification Error', errorMessages); + showNativeAlert('⚠️ IAPKit Verification Error', errorMessages); } } } catch (error) { - console.warn( + console.log( '[SubscriptionFlow] Verification failed:', getErrorMessage(error), ); - Alert.alert( + showNativeAlert( 'Verification Failed', `Purchase verification failed: ${getErrorMessage(error)}`, ); @@ -1868,23 +1895,42 @@ function SubscriptionFlowContainer() { // - Android: Acknowledges purchase (required within 3 days) // - Subscriptions are NOT consumable (isConsumable: false) const isConsumable = false; + let didFinishTransaction = false; if (!connectedRef.current) { console.log( '[SubscriptionFlow] Skipping finishTransaction - not connected yet', ); + setPurchaseResult( + 'Subscription received, waiting for store connection to finish transaction.', + ); const started = Date.now(); const tryFinish = () => { if (connectedRef.current) { finishTransaction({ purchase, isConsumable, - }).catch((err) => { - console.warn( - '[SubscriptionFlow] Delayed finishTransaction failed:', - err, - ); - }); + }) + .then(() => { + setPurchaseResult( + `Subscription activated and finished successfully.\n` + + `Product: ${purchase.productId}\n` + + `Transaction ID: ${purchase.id}\n` + + `Date: ${new Date( + purchase.transactionDate, + ).toLocaleDateString()}`, + ); + }) + .catch((err) => { + const message = getErrorMessage(err); + setPurchaseResult( + `Subscription activated, but finishTransaction failed: ${message}`, + ); + console.log( + '[SubscriptionFlow] Delayed finishTransaction failed:', + err, + ); + }); return; } if (Date.now() - started < 30000) { @@ -1893,10 +1939,25 @@ function SubscriptionFlowContainer() { }; setTimeout(tryFinish, 500); } else { - await finishTransaction({ - purchase, - isConsumable, - }); + try { + await finishTransaction({ + purchase, + isConsumable, + }); + didFinishTransaction = true; + setPurchaseResult( + `Subscription activated and finished successfully.\n` + + `Product: ${purchase.productId}\n` + + `Transaction ID: ${purchase.id}\n` + + `Date: ${new Date(purchase.transactionDate).toLocaleDateString()}`, + ); + } catch (err) { + const message = getErrorMessage(err); + setPurchaseResult( + `Subscription activated, but finishTransaction failed: ${message}`, + ); + console.log('[SubscriptionFlow] finishTransaction failed:', message); + } } // ────────────────────────────────────────────────────────────────────── @@ -1907,17 +1968,19 @@ function SubscriptionFlowContainer() { try { await getActiveSubscriptions(SUBSCRIPTION_PRODUCT_IDS); } catch (e) { - console.warn('Failed to refresh subscriptions:', getErrorMessage(e)); + console.log('Failed to refresh subscriptions:', getErrorMessage(e)); } - Alert.alert('Success', 'Purchase completed successfully!'); + if (didFinishTransaction) { + showNativeAlert('Success', 'Purchase completed successfully!'); + } }, // ──────────────────────────────────────────────────────────────────────── // Purchase Error Handler // ──────────────────────────────────────────────────────────────────────── onPurchaseError: (error: PurchaseError) => { - console.error('Subscription failed:', { + console.log('Subscription failed:', { code: error.code, message: error.message, }); @@ -1926,14 +1989,21 @@ function SubscriptionFlowContainer() { if (error?.code === ErrorCode.ServiceError && dt >= 0 && dt < 1500) { return; } + if (error.code === ErrorCode.UserCancelled) { + setPurchaseResult('Subscription cancelled by user'); + return; + } setPurchaseResult(`❌ Subscription failed: ${error.message}`); - Alert.alert('Subscription Failed', error.message); + showNativeAlert('Subscription Failed', error.message); }, }); useEffect(() => { connectedRef.current = connected; + if (!connected) { + cleanupPurchaseKeysRef.current.clear(); + } }, [connected]); // ────────────────────────────────────────────────────────────────────────── @@ -1947,11 +2017,61 @@ function SubscriptionFlowContainer() { fetchProducts({ skus: SUBSCRIPTION_PRODUCT_IDS, type: 'subs', + }).catch((error) => { + const message = getErrorMessage(error); + console.log('[SubscriptionFlow] fetchProducts error:', message); + setPurchaseResult(`❌ Subscription loading failed: ${message}`); }); + getAvailablePurchases() + .then(() => { + console.log('[SubscriptionFlow] getAvailablePurchases completed'); + }) + .catch((error) => { + console.log( + '[SubscriptionFlow] getAvailablePurchases error:', + error, + ); + }); fetchedProductsOnceRef.current = true; } } - }, [connected, fetchProducts]); + }, [connected, fetchProducts, getAvailablePurchases]); + + useEffect(() => { + if (!connected || availablePurchases.length === 0) return; + + for (const purchase of availablePurchases) { + const productId = purchase.productId ?? ''; + if (!isSubscriptionFlowProduct(productId)) { + console.log( + '[SubscriptionFlow] skipping cleanup for non-subscription product:', + {productId}, + ); + continue; + } + + const cleanupKey = getPurchaseCleanupKey(purchase); + if (cleanupPurchaseKeysRef.current.has(cleanupKey)) continue; + cleanupPurchaseKeysRef.current.add(cleanupKey); + + finishTransaction({ + purchase, + isConsumable: false, + }) + .then(() => { + console.log('[SubscriptionFlow] cleaned up available purchase:', { + productId, + }); + }) + .catch((error) => { + cleanupPurchaseKeysRef.current.delete(cleanupKey); + console.log( + '[SubscriptionFlow] available purchase cleanup failed:', + getErrorMessage(error), + ); + }); + } + }, [availablePurchases, connected, finishTransaction]); // 🔍 LOG: Check discount and promotional offer data useEffect(() => { @@ -2126,7 +2246,7 @@ function SubscriptionFlowContainer() { console.log('===================================\n'); } } catch (error) { - console.error( + console.log( 'Error checking subscription status:', getErrorMessage(error), ); @@ -2188,11 +2308,16 @@ function SubscriptionFlowContainer() { }, type: 'subs', }).catch((err: PurchaseError) => { - console.warn('requestPurchase failed:', { + console.log('requestPurchase failed:', { code: err.code, message: err.message, }); setIsProcessing(false); + if (err.code === ErrorCode.UserCancelled) { + setPurchaseResult('Subscription cancelled by user'); + return; + } + setPurchaseResult(`❌ Subscription failed: ${err.message}`); Alert.alert('Subscription Failed', err.message); }); @@ -2208,6 +2333,10 @@ function SubscriptionFlowContainer() { fetchProducts({ skus: SUBSCRIPTION_PRODUCT_IDS, type: 'subs', + }).catch((error) => { + const message = getErrorMessage(error); + console.log('[SubscriptionFlow] retry fetchProducts error:', message); + setPurchaseResult(`❌ Subscription loading failed: ${message}`); }); }, [fetchProducts]); @@ -2223,7 +2352,7 @@ function SubscriptionFlowContainer() { try { await deepLinkToSubscriptions(); } catch (error) { - console.warn( + console.log( 'Failed to open subscription management:', getErrorMessage(error), ); diff --git a/libraries/react-native-iap/example/screens/WebhookStream.tsx b/libraries/react-native-iap/example/screens/WebhookStream.tsx index 350a5c60..d036a57f 100644 --- a/libraries/react-native-iap/example/screens/WebhookStream.tsx +++ b/libraries/react-native-iap/example/screens/WebhookStream.tsx @@ -13,9 +13,14 @@ import { type WebhookListener, type WebhookListenerError, } from 'react-native-iap'; -import {IAPKIT_API_KEY} from '@env'; +import {IAPKIT_API_KEY, IAPKIT_BASE_URL} from '@env'; -const IAPKIT_BASE_URL = 'https://kit.openiap.dev'; +const DEFAULT_IAPKIT_BASE_URL = 'https://kit.openiap.dev'; + +type WebhookStreamProps = { + apiKey?: string; + baseUrl?: string; +}; export function base64EncodeUtf8(input: string): string { const btoaFn = (globalThis as {btoa?: (value: string) => string}).btoa; @@ -25,7 +30,10 @@ export function base64EncodeUtf8(input: string): string { return btoaFn(unescape(encodeURIComponent(input))); } -export default function WebhookStream() { +export default function WebhookStream({ + apiKey = IAPKIT_API_KEY, + baseUrl = IAPKIT_BASE_URL || DEFAULT_IAPKIT_BASE_URL, +}: WebhookStreamProps = {}) { const [events, setEvents] = useState([]); const [status, setStatus] = useState< 'idle' | 'connecting' | 'connected' | 'error' @@ -35,15 +43,15 @@ export default function WebhookStream() { const listenerRef = useRef(null); const startStream = useCallback(() => { - if (!IAPKIT_API_KEY) { + if (!apiKey) { setStatus('error'); setStatusMessage('IAPKIT_API_KEY is not configured.'); return; } listenerRef.current?.close(); listenerRef.current = connectWebhookStream({ - apiKey: IAPKIT_API_KEY, - baseUrl: IAPKIT_BASE_URL, + apiKey, + baseUrl, onEvent: (event) => { setStatusMessage(null); setEvents((prev) => [event, ...prev].slice(0, 50)); @@ -55,7 +63,7 @@ export default function WebhookStream() { }); setStatus('connected'); setStatusMessage(null); - }, []); + }, [apiKey, baseUrl]); const stopStream = useCallback(() => { listenerRef.current?.close(); @@ -72,7 +80,7 @@ export default function WebhookStream() { }, []); const triggerTestNotification = useCallback(async () => { - if (!IAPKIT_API_KEY) { + if (!apiKey) { setStatusMessage('Cannot trigger test: IAPKIT_API_KEY is missing.'); return; } @@ -85,7 +93,7 @@ export default function WebhookStream() { testNotification: {version: '1.0'}, }); const response = await fetch( - `${IAPKIT_BASE_URL}/v1/webhooks/${encodeURIComponent(IAPKIT_API_KEY)}`, + `${baseUrl.replace(/\/$/, '')}/v1/webhooks/${encodeURIComponent(apiKey)}`, { method: 'POST', headers: {'content-type': 'application/json'}, @@ -110,7 +118,7 @@ export default function WebhookStream() { } finally { setTesting(false); } - }, []); + }, [apiKey, baseUrl]); return ( @@ -118,8 +126,7 @@ export default function WebhookStream() { Webhook Stream IAPKit SSE + test notification - api key:{' '} - {IAPKIT_API_KEY ? 'CONFIGURED' : 'MISSING'} + api key: {apiKey ? 'CONFIGURED' : 'MISSING'} diff --git a/libraries/react-native-iap/example/scripts/build-vega-example.mjs b/libraries/react-native-iap/example/scripts/build-vega-example.mjs new file mode 100644 index 00000000..4fe7ea4f --- /dev/null +++ b/libraries/react-native-iap/example/scripts/build-vega-example.mjs @@ -0,0 +1,353 @@ +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const exampleRoot = path.resolve(__dirname, '..'); +const packageRoot = path.resolve(exampleRoot, '..'); +const tempRoot = path.join(os.tmpdir(), 'openiap-rn-iap-vega-example'); +const tempPackageSourceRoot = path.join( + tempRoot, + 'openiap-react-native-iap-src', +); +const buildType = process.argv[2] === 'Release' ? 'Release' : 'Debug'; +const iapkitApiKey = process.env.IAPKIT_API_KEY ?? ''; +const iapkitBaseUrl = process.env.IAPKIT_BASE_URL ?? ''; + +const writeFile = (relativePath, contents) => { + const filePath = path.join(tempRoot, relativePath); + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, contents, 'utf8'); +}; + +const copyFile = (source, relativeDestination) => { + const destination = path.join(tempRoot, relativeDestination); + fs.mkdirSync(path.dirname(destination), {recursive: true}); + fs.copyFileSync(source, destination); +}; + +const copyDirectory = (source, relativeDestination) => { + fs.cpSync(source, path.join(tempRoot, relativeDestination), { + recursive: true, + }); +}; + +const writeLocalPackageAlias = (packageName, entryPath) => { + const aliasRoot = path.join(tempRoot, 'node_modules', packageName); + const relativeEntry = path + .relative(aliasRoot, entryPath) + .replaceAll(path.sep, '/'); + const importPath = relativeEntry.startsWith('.') + ? relativeEntry + : `./${relativeEntry}`; + + fs.mkdirSync(aliasRoot, {recursive: true}); + fs.writeFileSync( + path.join(aliasRoot, 'package.json'), + `${JSON.stringify( + { + name: packageName, + version: '0.0.0-local', + main: 'index.ts', + 'react-native': 'index.ts', + }, + null, + 2, + )}\n`, + 'utf8', + ); + fs.writeFileSync( + path.join(aliasRoot, 'index.ts'), + `export * from ${JSON.stringify(importPath)};\n`, + 'utf8', + ); +}; + +const writeLocalEntryModule = (relativePath, entryPath) => { + const localRoot = path.dirname(path.join(tempRoot, relativePath)); + const relativeEntry = path + .relative(localRoot, entryPath) + .replaceAll(path.sep, '/'); + const importPath = relativeEntry.startsWith('.') + ? relativeEntry + : `./${relativeEntry}`; + + writeFile(relativePath, `export * from ${JSON.stringify(importPath)};\n`); +}; + +const writeLocalJavaScriptModule = (packageName, source) => { + const moduleRoot = path.join(tempRoot, 'node_modules', packageName); + fs.mkdirSync(moduleRoot, {recursive: true}); + fs.writeFileSync( + path.join(moduleRoot, 'package.json'), + `${JSON.stringify( + { + name: packageName, + version: '0.0.0-local', + main: 'index.js', + }, + null, + 2, + )}\n`, + 'utf8', + ); + fs.writeFileSync(path.join(moduleRoot, 'index.js'), source, 'utf8'); +}; + +const copyDirectoryWithTransform = ( + sourceRoot, + relativeDestination, + transform, +) => { + for (const entry of fs.readdirSync(sourceRoot, {withFileTypes: true})) { + const sourcePath = path.join(sourceRoot, entry.name); + const destinationPath = path.join(relativeDestination, entry.name); + + if (entry.isDirectory()) { + copyDirectoryWithTransform(sourcePath, destinationPath, transform); + continue; + } + + if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) { + writeFile( + destinationPath, + transform(fs.readFileSync(sourcePath, 'utf8'), sourcePath), + ); + continue; + } + + copyFile(sourcePath, destinationPath); + } +}; + +const rewritePackageSourceImports = (source, sourcePath) => { + const relativeSourcePath = path + .relative(path.join(packageRoot, 'src'), sourcePath) + .replaceAll(path.sep, '/'); + + if (relativeSourcePath === 'hooks/useIAP.ts') { + return source + .replaceAll("from '../'", "from '../index.kepler'") + .replaceAll('from "../"', 'from "../index.kepler"'); + } + + return source; +}; + +const copyExampleSources = () => { + copyFile(path.join(exampleRoot, 'App.kepler.tsx'), 'App.tsx'); + copyDirectory(path.join(exampleRoot, 'screens'), 'screens'); + copyDirectory(path.join(exampleRoot, 'src'), 'src'); +}; + +const writeExampleShims = () => { + writeLocalJavaScriptModule( + '@env', + `export const IAPKIT_API_KEY = ${JSON.stringify(iapkitApiKey)}; +export const IAPKIT_BASE_URL = ${JSON.stringify(iapkitBaseUrl)}; +`, + ); + writeLocalJavaScriptModule( + '@react-native-clipboard/clipboard', + `let value = ''; + +export const setString = (nextValue) => { + value = String(nextValue ?? ''); +}; + +export const getString = async () => value; + +export default { + setString, + getString, +}; +`, + ); +}; + +const run = (command, args, cwd = tempRoot) => { + execFileSync(command, args, { + cwd, + env: { + ...process.env, + RN_IAP_DEV_MODE: 'true', + RN_IAP_VEGA: '1', + }, + stdio: 'inherit', + }); +}; + +fs.rmSync(tempRoot, {force: true, recursive: true}); +fs.mkdirSync(tempRoot, {recursive: true}); +copyDirectoryWithTransform( + path.join(packageRoot, 'src'), + path.relative(tempRoot, tempPackageSourceRoot), + rewritePackageSourceImports, +); + +writeFile( + 'package.json', + `${JSON.stringify( + { + name: 'rniapvegaexample', + version: '0.0.1', + private: true, + scripts: { + 'build:vega': `react-native build-vega --build-type ${buildType} --reset-cache`, + }, + dependencies: { + '@amazon-devices/keplerscript-appstore-iap-lib': '~2.12.13', + '@amazon-devices/react-native-kepler': '^2.0.0', + react: '18.2.0', + 'react-native': '0.72.0', + }, + devDependencies: { + '@amazon-devices/kepler-cli-platform': '~0.22.0', + '@react-native-community/cli': '11.3.2', + '@react-native/metro-config': '^0.72.6', + 'metro-react-native-babel-preset': '~0.76.9', + typescript: '4.8.4', + }, + kepler: { + projectType: 'application', + appName: 'RNIapVegaExample', + targets: ['tv'], + os: ['vega'], + }, + }, + null, + 2, + )}\n`, +); + +writeFile( + 'app.json', + `${JSON.stringify( + { + '//': 'The declared app name must match the Vega component id.', + name: 'dev.hyo.openiap.rniap.example.main', + displayName: 'React Native IAP Vega Example', + }, + null, + 2, + )}\n`, +); + +writeFile( + 'index.js', + `import {AppRegistry} from 'react-native'; +import App from './App'; +import {name as appName} from './app.json'; + +AppRegistry.registerComponent(appName, () => App); +`, +); + +writeFile( + 'babel.config.js', + `module.exports = { + presets: ['module:metro-react-native-babel-preset'], +}; +`, +); + +writeFile( + 'metro.config.js', + `const path = require('path'); +const { + getDefaultConfig, + mergeConfig, +} = require('@react-native/metro-config'); + +const packageSourceRoot = ${JSON.stringify(tempPackageSourceRoot)}; +const tempNodeModules = path.resolve(__dirname, 'node_modules'); + +const resolveFromTemp = (moduleName) => + require.resolve(moduleName, {paths: [tempNodeModules]}); + +const vegaConfig = { + resolver: { + resolveRequest: (context, moduleName, platform) => { + if ( + moduleName === 'react' || + moduleName === 'react/jsx-runtime' || + moduleName === 'react/jsx-dev-runtime' || + moduleName === 'react-native' + ) { + return { + type: 'sourceFile', + filePath: resolveFromTemp(moduleName), + }; + } + + if (moduleName === 'react-native-iap') { + return { + type: 'sourceFile', + filePath: path.join(packageSourceRoot, 'index.kepler.ts'), + }; + } + + return context.resolveRequest(context, moduleName, platform); + }, + disableHierarchicalLookup: true, + extraNodeModules: new Proxy( + {}, + { + get: (_target, moduleName) => + path.join(tempNodeModules, String(moduleName)), + }, + ), + nodeModulesPaths: [tempNodeModules], + }, + watchFolders: [packageSourceRoot], +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), vegaConfig); +`, +); + +copyExampleSources(); +writeLocalEntryModule( + 'openiap-react-native-iap.ts', + path.join(tempPackageSourceRoot, 'index.kepler.ts'), +); +copyFile(path.join(exampleRoot, 'manifest.toml'), 'manifest.toml'); + +run('bun', ['install', '--force']); +writeLocalPackageAlias( + 'react-native-iap', + path.join(tempPackageSourceRoot, 'index.kepler.ts'), +); +writeExampleShims(); +run('./node_modules/.bin/react-native', [ + 'build-vega', + '--build-type', + buildType, + '--reset-cache', +]); + +const outputDir = path.join( + exampleRoot, + 'build', + buildType === 'Release' ? 'armv7-release' : 'armv7-debug', +); +fs.rmSync(outputDir, {force: true, recursive: true}); +fs.mkdirSync(outputDir, {recursive: true}); + +const tempOutputDir = path.join( + tempRoot, + 'build', + buildType === 'Release' ? 'armv7-release' : 'armv7-debug', +); +for (const fileName of fs.readdirSync(tempOutputDir)) { + if (fileName.endsWith('.vpkg')) { + fs.copyFileSync( + path.join(tempOutputDir, fileName), + path.join(outputDir, fileName), + ); + } +} + +console.log(`Vega ${buildType} build copied to ${outputDir}`); diff --git a/libraries/react-native-iap/example/scripts/run-vega-firetv.mjs b/libraries/react-native-iap/example/scripts/run-vega-firetv.mjs new file mode 100644 index 00000000..378149f2 --- /dev/null +++ b/libraries/react-native-iap/example/scripts/run-vega-firetv.mjs @@ -0,0 +1,270 @@ +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const exampleRoot = path.resolve(__dirname, '..'); +const packageFile = path.join( + exampleRoot, + 'build/armv7-debug/rniapvegaexample_armv7.vpkg', +); +const packageId = 'dev.hyo.openiap.rniap.example'; +const appId = 'dev.hyo.openiap.rniap.example.main'; +const appTesterPackageId = 'com.amazonappstore.iap.tester'; +const appTesterUi = 'pkg://com.amazonappstore.iap.tester.ui'; +const tcpDevicePattern = /^.+:\d+$/; +const shouldLaunchAppTesterUi = process.env.VEGA_LAUNCH_APP_TESTER_UI === '1'; + +const run = (args, options = {}) => { + try { + return execFileSync('vega', args, { + cwd: exampleRoot, + encoding: options.encoding, + stdio: options.encoding ? ['ignore', 'pipe', 'pipe'] : 'inherit', + timeout: options.timeout, + }); + } catch (error) { + if (options.allowFailure) return ''; + throw error; + } +}; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const findFirstVegaDeviceId = (output) => { + for (const line of output.split(/\r?\n/)) { + const match = line.trim().match(/^(\S+)\s+:/); + if (match) return match[1]; + } + + return undefined; +}; + +const resolveDeviceId = () => { + if (process.env.VEGA_DEVICE_ID) return process.env.VEGA_DEVICE_ID; + + const deviceListOutput = run(['device', 'list'], { + allowFailure: true, + encoding: 'utf8', + }); + const vegaDeviceId = findFirstVegaDeviceId(deviceListOutput); + if (vegaDeviceId) return vegaDeviceId; + + const output = run(['exec', 'vda', 'devices'], {encoding: 'utf8'}); + const deviceLine = output + .split(/\r?\n/) + .find((line) => /\sdevice$/.test(line.trim())); + const deviceId = deviceLine?.trim().split(/\s+/)[0]; + + if (!deviceId) { + throw new Error( + 'No Vega device found. Connect a Fire TV device or set VEGA_DEVICE_ID.', + ); + } + + return deviceId; +}; + +const sleep = (milliseconds) => + new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + +const canReachTcpDevice = (deviceId) => + new Promise((resolve) => { + if (!tcpDevicePattern.test(deviceId)) { + resolve(true); + return; + } + + const [host, portText] = deviceId.split(/:(?=\d+$)/); + const socket = new net.Socket(); + let resolved = false; + const finish = (result) => { + if (resolved) return; + resolved = true; + socket.destroy(); + resolve(result); + }; + + socket.setTimeout(1500); + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.once('timeout', () => finish(false)); + socket.connect(Number(portText), host); + }); + +const isDeviceReady = (deviceId) => { + const output = run(['exec', 'vda', 'devices'], { + allowFailure: true, + encoding: 'utf8', + }); + + return output + .split(/\r?\n/) + .some((line) => line.trim() === `${deviceId}\tdevice`); +}; + +const waitForDevice = async (deviceId) => { + for (let attempt = 1; attempt <= 8; attempt += 1) { + if (isDeviceReady(deviceId)) return; + + run(['exec', 'vda', 'reconnect', 'offline'], {allowFailure: true}); + if ( + tcpDevicePattern.test(deviceId) && + (await canReachTcpDevice(deviceId)) + ) { + run(['exec', 'vda', 'disconnect', deviceId], {allowFailure: true}); + run(['exec', 'vda', 'connect', deviceId], {allowFailure: true}); + } + await sleep(2000); + } + + throw new Error( + `Vega device ${deviceId} is not connected. Confirm the Fire TV is powered on, Developer Mode is enabled, and VDA is available.`, + ); +}; + +const shell = (deviceId, args, options = {}) => { + run(['exec', 'vda', '-s', deviceId, 'shell', ...args], options); +}; + +const shellOutput = (deviceId, args) => + run(['exec', 'vda', '-s', deviceId, 'shell', ...args], { + allowFailure: true, + encoding: 'utf8', + }); + +const pushToDevice = (deviceId, source, destination) => { + run(['exec', 'vda', '-s', deviceId, 'push', source, destination]); +}; + +const copyToDevice = (deviceId, source, destinationDirectory, options = {}) => { + run([ + 'device', + 'copy-to', + '-d', + deviceId, + '-s', + source, + '-o', + destinationDirectory, + ], options); +}; + +if (!fs.existsSync(packageFile)) { + throw new Error( + `Missing ${path.relative(exampleRoot, packageFile)}. Run yarn build:vega:debug first.`, + ); +} + +const deviceId = resolveDeviceId(); +await waitForDevice(deviceId); +const appTesterCatalog = path.join(exampleRoot, 'amazon.sdktester.json'); +const appSandboxConfig = path.join(exampleRoot, 'amazon.config.json'); +const launchApp = () => { + run(['device', '-d', deviceId, 'launch-app', '--appName', appId], { + allowFailure: true, + }); +}; +const cancelQueuedInstalls = () => { + const output = shellOutput(deviceId, ['vpm', 'query-installs']); + const packageBaseName = path.basename(packageFile, '.vpkg'); + const tokenPattern = new RegExp( + `InstallRequestStatus token: (\\S*${escapeRegExp(packageBaseName)}\\S*) status: REQUEST_ENQUEUED`, + 'g', + ); + const tokens = new Set( + [...output.matchAll(tokenPattern)].map((match) => match[1]), + ); + + for (const token of tokens) { + shell(deviceId, ['vpm', 'cancel-download', token, '--timeout=5'], { + allowFailure: true, + }); + } +}; +const installApp = () => { + try { + run(['device', '-d', deviceId, 'install-app', '--packagePath', packageFile], { + timeout: 90000, + }); + return; + } catch (error) { + console.warn( + 'vega device install-app failed; retrying with vpm install-async.', + ); + } + + cancelQueuedInstalls(); + const remotePackageFile = `/tmp/${path.basename(packageFile)}`; + const token = `${path.basename(packageFile, '.vpkg')}_${Date.now()}`; + pushToDevice(deviceId, packageFile, remotePackageFile); + shell(deviceId, [ + 'vpm', + 'install-async', + remotePackageFile, + `--token=${token}`, + '--timeout=60', + '--high-priority', + '--force', + '--update-max-timeout=5', + '--terminate-on-max-timeout', + ]); + shell(deviceId, ['vpm', 'query-installs', `--token=${token}`], { + allowFailure: true, + }); +}; +const submitParentalPin = () => { + if (!process.env.VEGA_PARENTAL_PIN) return; + + for (const digit of process.env.VEGA_PARENTAL_PIN) { + shell(deviceId, ['inputd-cli', 'button_press', `KEY_${digit}`], { + allowFailure: true, + }); + } +}; + +run(['device', '-d', deviceId, 'terminate-app', '--appName', appId], { + allowFailure: true, +}); +run(['device', '-d', deviceId, 'uninstall-app', '--appName', appId], { + allowFailure: true, +}); + +shell( + deviceId, + [ + 'mkdir', + '-p', + `/tmp/scratch/${appTesterPackageId}`, + `/tmp/scratch/${packageId}`, + `/tmp/scratch/${appId}`, + ], + { + allowFailure: true, + encoding: 'utf8', + }, +); +copyToDevice(deviceId, appTesterCatalog, `/tmp/scratch/${appTesterPackageId}`); +copyToDevice(deviceId, appSandboxConfig, `/tmp/scratch/${packageId}`, { + allowFailure: true, + encoding: 'utf8', +}); +copyToDevice(deviceId, appSandboxConfig, `/tmp/scratch/${appId}`); + +shell( + deviceId, + ['vlcm', 'terminate-app', '--pkg-id', appTesterPackageId, '--force'], + { + allowFailure: true, + }, +); +if (shouldLaunchAppTesterUi) { + shell(deviceId, ['vlcm', 'launch-app', appTesterUi], {allowFailure: true}); +} +installApp(); +launchApp(); +submitParentalPin(); diff --git a/libraries/react-native-iap/example/src/hooks/useVerificationMethod.ts b/libraries/react-native-iap/example/src/hooks/useVerificationMethod.ts index 6f4b9c50..59357c97 100644 --- a/libraries/react-native-iap/example/src/hooks/useVerificationMethod.ts +++ b/libraries/react-native-iap/example/src/hooks/useVerificationMethod.ts @@ -3,6 +3,12 @@ import {Platform, ActionSheetIOS, Alert} from 'react-native'; export type VerificationMethod = 'ignore' | 'local' | 'iapkit'; +export function getDefaultVerificationMethod( + iapkitApiKey?: string | null, +): VerificationMethod { + return iapkitApiKey?.trim() ? 'iapkit' : 'ignore'; +} + interface UseVerificationMethodReturn { verificationMethod: VerificationMethod; verificationMethodRef: React.MutableRefObject; diff --git a/libraries/react-native-iap/example/src/types/env.d.ts b/libraries/react-native-iap/example/src/types/env.d.ts index cf49bbd3..e5c2d22f 100644 --- a/libraries/react-native-iap/example/src/types/env.d.ts +++ b/libraries/react-native-iap/example/src/types/env.d.ts @@ -1,3 +1,4 @@ declare module '@env' { export const IAPKIT_API_KEY: string; + export const IAPKIT_BASE_URL: string; } diff --git a/libraries/react-native-iap/example/src/utils/vegaRuntime.ts b/libraries/react-native-iap/example/src/utils/vegaRuntime.ts new file mode 100644 index 00000000..0eca5a80 --- /dev/null +++ b/libraries/react-native-iap/example/src/utils/vegaRuntime.ts @@ -0,0 +1,84 @@ +import {Alert, Platform} from 'react-native'; +import type {Purchase, VerifyPurchaseWithProviderProps} from 'react-native-iap'; + +export type IapkitVerificationPayload = NonNullable< + VerifyPurchaseWithProviderProps['iapkit'] +> & { + baseUrl?: string | null; +}; + +function withIapkitEndpoint( + payload: IapkitVerificationPayload, + baseUrl?: string | null, +): IapkitVerificationPayload { + const trimmedBaseUrl = baseUrl?.trim(); + if (!trimmedBaseUrl) { + return payload; + } + return { + ...payload, + baseUrl: trimmedBaseUrl, + }; +} + +export function showNativeAlert(title: string, message?: string): void { + const shouldSuppressAlerts = Boolean( + (globalThis as {RN_IAP_SUPPRESS_NATIVE_ALERTS?: boolean}) + .RN_IAP_SUPPRESS_NATIVE_ALERTS, + ); + if (!shouldSuppressAlerts) { + Alert.alert(title, message); + } +} + +export function createIapkitVerificationPayload( + purchase: Purchase, + purchaseToken: string, + apiKey: string, + baseUrl?: string | null, +): IapkitVerificationPayload { + const purchaseStore = ( + (purchase as Purchase & {store?: string | null}).store ?? '' + ).toLowerCase(); + if (purchaseStore === 'amazon') { + return withIapkitEndpoint( + { + apiKey, + amazon: { + receiptId: purchaseToken, + sandbox: __DEV__, + }, + }, + baseUrl, + ); + } + + const isApplePurchase = + purchaseStore === 'apple' || (!purchaseStore && Platform.OS === 'ios'); + + return withIapkitEndpoint( + isApplePurchase + ? { + apiKey, + apple: { + jws: purchaseToken, + }, + } + : { + apiKey, + google: { + purchaseToken, + }, + }, + baseUrl, + ); +} + +export function getPurchaseCleanupKey(purchase: Purchase): string { + return ( + purchase.purchaseToken ?? + purchase.id ?? + purchase.productId ?? + `${purchase.transactionDate ?? Date.now()}` + ); +} diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 14034b81..76dc7aab 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -443,6 +443,18 @@ class HybridRnIap: HybridRnIapSpec { if case .second(let google) = iapkit.google { iapkitDict["google"] = ["purchaseToken": google.purchaseToken] } + if case .second(let amazon) = iapkit.amazon { + var amazonDict: [String: Any] = [ + "receiptId": amazon.receiptId + ] + if case .second(let sandbox) = amazon.sandbox { + amazonDict["sandbox"] = sandbox + } + if case .second(let userId) = amazon.userId { + amazonDict["userId"] = userId + } + iapkitDict["amazon"] = amazonDict + } propsDict["iapkit"] = iapkitDict } // Use JSONSerialization + JSONDecoder like expo-iap does diff --git a/libraries/react-native-iap/package.json b/libraries/react-native-iap/package.json index a2d3eea4..6b17fd36 100644 --- a/libraries/react-native-iap/package.json +++ b/libraries/react-native-iap/package.json @@ -1,15 +1,15 @@ { "name": "react-native-iap", - "version": "15.3.2", + "version": "15.4.0-rc.1", "description": "React Native In-App Purchases module for iOS and Android using Nitro", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", "exports": { ".": { "source": "./src/index.ts", + "react-native": "./src/index.ts", "types": "./lib/typescript/src/index.d.ts", - "default": "./lib/module/index.js", - "react-native": "./src/index.ts" + "default": "./lib/module/index.js" }, "./plugin": "./app.plugin.js", "./app.plugin.js": "./app.plugin.js", @@ -34,9 +34,12 @@ "!android/gradlew", "!android/gradlew.bat", "!android/local.properties", + "!android/src/androidTest", + "!android/src/test", "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", + "!**/*.tsbuildinfo", "!**/.*" ], "scripts": { @@ -59,7 +62,7 @@ "test": "jest --coverage", "test:library": "jest --coverage", "test:example": "yarn workspace rn-iap-example test --coverage", - "test:plugin": "jest plugin --coverage", + "test:plugin": "jest plugin/__tests__ --runInBand --coverage", "test:all": "yarn test:library && yarn test:example", "test:ci": "jest --maxWorkers=2 --coverage", "test:ci:example": "yarn workspace rn-iap-example test --coverage", @@ -118,10 +121,20 @@ "typescript": "^5.9.2" }, "peerDependencies": { + "@amazon-devices/keplerscript-appstore-iap-lib": "~2.12.13", + "@amazon-devices/package-manager-lib": "~1.0.1767254401", "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.35.0" }, + "peerDependenciesMeta": { + "@amazon-devices/keplerscript-appstore-iap-lib": { + "optional": true + }, + "@amazon-devices/package-manager-lib": { + "optional": true + } + }, "workspaces": [ "example" ], diff --git a/libraries/react-native-iap/plugin/__tests__/withIAP-android.test.ts b/libraries/react-native-iap/plugin/__tests__/withIAP-android.test.ts index 06dd8c67..5ad9f7d6 100644 --- a/libraries/react-native-iap/plugin/__tests__/withIAP-android.test.ts +++ b/libraries/react-native-iap/plugin/__tests__/withIAP-android.test.ts @@ -1,6 +1,6 @@ import {readFileSync} from 'node:fs'; import {resolve as resolvePath} from 'node:path'; -import plugin from '../src/withIAP'; +import plugin, {resolveAmazonPlatformFlags} from '../src/withIAP'; const versionsPath = resolvePath(__dirname, '../../openiap-versions.json'); const openiapVersions = JSON.parse(readFileSync(versionsPath, 'utf8')); @@ -30,6 +30,16 @@ jest.mock('expo/config-plugins', () => ({ const updated = {...original, contents: result.modResults.contents}; return {...config, modResults: updated}; }, + withGradleProperties: (config: any, action: any) => { + const original = config.modResults; + const cfg = {...config, modResults: original.gradleProperties ?? []}; + const result = action(cfg); + const updated = { + ...original, + gradleProperties: result.modResults, + }; + return {...config, modResults: updated}; + }, withInfoPlist: (config: any, action: any) => { const original = config.modResults; const cfg = {...config, modResults: original.plist ?? {}}; @@ -60,16 +70,19 @@ jest.mock('expo/config-plugins', () => ({ describe('withIAP config plugin (Android)', () => { const originalLog = console.log; const originalWarn = console.warn; + const originalError = console.error; beforeEach(() => { jest.resetModules(); console.log = jest.fn(); console.warn = jest.fn(); + console.error = jest.fn(); }); afterEach(() => { console.log = originalLog; console.warn = originalWarn; + console.error = originalError; }); function makeConfig(gradle: string, manifest?: any) { @@ -77,6 +90,7 @@ describe('withIAP config plugin (Android)', () => { modResults: { contents: gradle, manifest: manifest ?? {manifest: {}}, + gradleProperties: [], plist: {}, entitlements: {}, podfile: '', @@ -115,6 +129,20 @@ describe('withIAP config plugin (Android)', () => { expect(after).toBe(before); }); + it('adds OpenIAP dep when coordinate appears only in a comment', () => { + const initial = [ + '// io.github.hyochan.openiap:openiap-google appears in a comment', + 'dependencies {', + '}', + ].join('\n'); + const config = makeConfig(initial, {manifest: {}}); + const result: any = plugin(config as any); + + expect(result.modResults.contents).toContain( + `implementation "io.github.hyochan.openiap:openiap-google:${OPENIAP_VERSION}"`, + ); + }); + it('adds BILLING permission to AndroidManifest if missing', () => { const config = makeConfig('dependencies {\n}', {manifest: {}}); const res: any = plugin(config as any); @@ -170,4 +198,126 @@ describe('withIAP config plugin (Android)', () => { const app = res.modResults.manifest.manifest.application[0]; expect(app['meta-data']).toBeUndefined(); }); + + it('uses Fire OS artifact, flavor, and removes Play Billing permission when Fire OS is enabled', () => { + const initial = [ + 'android {', + ' defaultConfig {', + ' }', + '}', + 'dependencies {', + ` implementation "io.github.hyochan.openiap:openiap-google-horizon:0.0.1"`, + '}', + '', + ].join('\n'); + const manifest = { + manifest: { + 'uses-permission': [ + {$: {'android:name': 'com.android.vending.BILLING'}}, + ], + }, + }; + const config = makeConfig(initial, manifest); + const res: any = plugin(config as any, {amazon: {fireOS: true}}); + + expect(res.modResults.contents).toContain( + `io.github.hyochan.openiap:openiap-google-amazon:${OPENIAP_VERSION}`, + ); + expect(res.modResults.contents).toContain( + 'missingDimensionStrategy "platform", "amazon"', + ); + expect(res.modResults.contents).not.toContain( + 'openiap-google-horizon:0.0.1', + ); + expect(res.modResults.manifest.manifest['uses-permission']).toHaveLength(0); + expect(res.modResults.gradleProperties).toEqual( + expect.arrayContaining([ + {type: 'property', key: 'horizonEnabled', value: 'false'}, + {type: 'property', key: 'fireOsEnabled', value: 'true'}, + ]), + ); + }); + + it('prefers Fire OS over Horizon when both Android modules are enabled', () => { + const initial = `android {\n defaultConfig {\n }\n}\n\ndependencies {\n}`; + const config = makeConfig(initial, {manifest: {}}); + const res: any = plugin(config as any, { + amazon: {fireOS: true}, + modules: {horizon: true}, + }); + + expect(res.modResults.contents).toContain( + `io.github.hyochan.openiap:openiap-google-amazon:${OPENIAP_VERSION}`, + ); + expect(res.modResults.contents).toContain( + 'missingDimensionStrategy "platform", "amazon"', + ); + expect(res.modResults.gradleProperties).toEqual( + expect.arrayContaining([ + {type: 'property', key: 'horizonEnabled', value: 'false'}, + {type: 'property', key: 'fireOsEnabled', value: 'true'}, + ]), + ); + }); + + it('allows Fire OS and Vega OS to be enabled as Amazon targets', () => { + const initial = `android {\n defaultConfig {\n }\n}\n\ndependencies {\n}`; + const config = makeConfig(initial, {manifest: {}}); + const res: any = plugin(config as any, { + amazon: {fireOS: true, vegaOS: true}, + }); + + expect(res.modResults.contents).toContain( + `io.github.hyochan.openiap:openiap-google-amazon:${OPENIAP_VERSION}`, + ); + expect(res.modResults.contents).toContain( + 'missingDimensionStrategy "platform", "amazon"', + ); + expect(resolveAmazonPlatformFlags({amazon: {fireOS: true, vegaOS: true}})) + .toEqual({ + isFireOsEnabled: true, + isVegaEnabled: true, + isHorizonEnabled: false, + }); + }); + + it('keeps Horizon outside the Amazon platform group', () => { + expect(resolveAmazonPlatformFlags({modules: {horizon: true}})).toEqual({ + isFireOsEnabled: false, + isVegaEnabled: false, + isHorizonEnabled: true, + }); + }); + + it('replaces stale Amazon artifact and platform strategy when returning to Play', () => { + const initial = [ + 'android {', + ' defaultConfig {', + ' missingDimensionStrategy "platform", "amazon"', + ' }', + '}', + 'dependencies {', + ` implementation "io.github.hyochan.openiap:openiap-google-amazon:0.0.1"`, + '}', + '', + ].join('\n'); + const config = makeConfig(initial, {manifest: {}}); + const res: any = plugin(config as any); + + expect(res.modResults.contents).toContain( + `io.github.hyochan.openiap:openiap-google:${OPENIAP_VERSION}`, + ); + expect(res.modResults.contents).not.toContain( + 'openiap-google-amazon:0.0.1', + ); + expect(res.modResults.contents).toContain( + 'missingDimensionStrategy "platform", "play"', + ); + expect(res.modResults.gradleProperties).toEqual( + expect.arrayContaining([ + {type: 'property', key: 'horizonEnabled', value: 'false'}, + {type: 'property', key: 'fireOsEnabled', value: 'false'}, + ]), + ); + }); }); diff --git a/libraries/react-native-iap/plugin/__tests__/withIAP-ios.test.ts b/libraries/react-native-iap/plugin/__tests__/withIAP-ios.test.ts index 6e6247ab..50f08867 100644 --- a/libraries/react-native-iap/plugin/__tests__/withIAP-ios.test.ts +++ b/libraries/react-native-iap/plugin/__tests__/withIAP-ios.test.ts @@ -17,6 +17,16 @@ jest.mock('expo/config-plugins', () => ({ const updated = {...original, contents: result.modResults.contents}; return {...config, modResults: updated}; }, + withGradleProperties: (config: any, action: any) => { + const original = config.modResults; + const cfg = {...config, modResults: original.gradleProperties ?? []}; + const result = action(cfg); + const updated = { + ...original, + gradleProperties: result.modResults, + }; + return {...config, modResults: updated}; + }, withInfoPlist: (config: any, action: any) => { const original = config.modResults; const cfg = {...config, modResults: original.plist ?? {}}; @@ -70,6 +80,7 @@ describe('withIAP config plugin (iOS)', () => { modResults: { contents: options?.gradle ?? 'dependencies {\n}', manifest: options?.manifest ?? {manifest: {application: [{}]}}, + gradleProperties: [], plist: options?.plist ?? {}, entitlements: options?.entitlements ?? {}, podfile: options?.podfile ?? '', diff --git a/libraries/react-native-iap/plugin/src/withIAP.ts b/libraries/react-native-iap/plugin/src/withIAP.ts index 56c34704..b07c5431 100644 --- a/libraries/react-native-iap/plugin/src/withIAP.ts +++ b/libraries/react-native-iap/plugin/src/withIAP.ts @@ -3,14 +3,13 @@ import { WarningAggregator, withAndroidManifest, withAppBuildGradle, + withGradleProperties, withPodfile, withEntitlementsPlist, withInfoPlist, } from 'expo/config-plugins'; import type {ConfigPlugin} from 'expo/config-plugins'; import type {ExpoConfig} from '@expo/config-types'; -import {readFileSync} from 'node:fs'; -import {resolve as resolvePath} from 'node:path'; const pkg = require('../../package.json'); @@ -49,43 +48,40 @@ export const modifyProjectBuildGradle = (gradle: string): string => { return gradle; }; -const OPENIAP_COORD = 'io.github.hyochan.openiap:openiap-google'; +const OPENIAP_GROUP = 'io.github.hyochan.openiap'; -function loadOpenIapConfig(): {google: string} { - const versionsPath = resolvePath(__dirname, '../../openiap-versions.json'); - try { - const raw = readFileSync(versionsPath, 'utf8'); - const parsed = JSON.parse(raw); - const googleVersion = - typeof parsed?.google === 'string' ? parsed.google.trim() : ''; - if (!googleVersion) { +const modifyAppBuildGradle = ( + gradle: string, + isHorizonEnabled?: boolean, + isFireOsEnabled?: boolean, +): string => { + const escapeRegExp = (value: string): string => { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }; + + function loadOpenIapVersion(): string { + try { + const parsed = require('../../openiap-versions.json'); + const googleVersion = + typeof parsed?.google === 'string' ? parsed.google.trim() : ''; + if (!googleVersion) { + throw new Error( + 'react-native-iap: "google" version missing or invalid in openiap-versions.json', + ); + } + return googleVersion; + } catch (error) { throw new Error( - 'react-native-iap: "google" version missing or invalid in openiap-versions.json', + `react-native-iap: Unable to load openiap-versions.json (${error instanceof Error ? error.message : error})`, ); } - return {google: googleVersion}; - } catch (error) { - throw new Error( - `react-native-iap: Unable to load openiap-versions.json (${error instanceof Error ? error.message : error})`, - ); - } -} - -let cachedOpenIapVersion: string | null = null; -const getOpenIapVersion = (): string => { - if (cachedOpenIapVersion) { - return cachedOpenIapVersion; } - cachedOpenIapVersion = loadOpenIapConfig().google; - return cachedOpenIapVersion; -}; -const modifyAppBuildGradle = (gradle: string): string => { let modified = gradle; let openiapVersion: string; try { - openiapVersion = getOpenIapVersion(); + openiapVersion = loadOpenIapVersion(); } catch (error) { WarningAggregator.addWarningAndroid( 'react-native-iap', @@ -107,9 +103,34 @@ const modifyAppBuildGradle = (gradle: string): string => { ) .replace(/\n{3,}/g, '\n\n'); - const openiapDep = ` implementation "${OPENIAP_COORD}:${openiapVersion}"`; + const flavor = isFireOsEnabled + ? 'amazon' + : isHorizonEnabled + ? 'horizon' + : 'play'; + const artifactId = isFireOsEnabled + ? 'openiap-google-amazon' + : isHorizonEnabled + ? 'openiap-google-horizon' + : 'openiap-google'; + const openiapCoord = `${OPENIAP_GROUP}:${artifactId}`; + const openiapDep = ` implementation "${openiapCoord}:${openiapVersion}"`; + const desiredOpeniapLine = new RegExp( + `^[ \\t]*(?:implementation|api)[ \\t]+\\(?["']${escapeRegExp( + `${openiapCoord}:${openiapVersion}`, + )}["']\\)?[ \\t]*$`, + 'm', + ); + + const openiapAnyLine = + /^[ \t]*(implementation|api)[ \t]+\(?["']io\.github\.hyochan\.openiap:openiap-google(?:-(?:horizon|amazon))?:[^"']+["']\)?[ \t]*$/gim; + const withoutExistingOpeniap = modified.replace(openiapAnyLine, ''); + const hadExistingOpeniap = withoutExistingOpeniap !== modified; + if (hadExistingOpeniap) { + modified = withoutExistingOpeniap.replace(/\n{3,}/g, '\n\n'); + } - if (!modified.includes(OPENIAP_COORD)) { + if (!desiredOpeniapLine.test(modified)) { if (!/dependencies\s*{/.test(modified)) { modified += `\n\ndependencies {\n${openiapDep}\n}\n`; } else { @@ -122,45 +143,101 @@ const modifyAppBuildGradle = (gradle: string): string => { } } + // 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 = + /^[ \t]*missingDimensionStrategy[ \t]*\(?[ \t]*["']platform["'][ \t]*,[ \t]*["'](play|horizon|amazon)["'][ \t]*\)?[ \t]*$/gm; + const withoutExistingStrategy = modified.replace(strategyPattern, ''); + if (withoutExistingStrategy !== modified) { + modified = withoutExistingStrategy; + } + + const defaultConfigRegex = /defaultConfig\s*{/; + if (defaultConfigRegex.test(modified)) { + const strategyLine = ` missingDimensionStrategy "platform", "${flavor}"`; + if (!/missingDimensionStrategy.*platform/.test(modified)) { + modified = addLineToGradle(modified, defaultConfigRegex, strategyLine); + } + } + return modified; }; -const withIapAndroid: ConfigPlugin<{iapkitApiKey?: string} | undefined> = ( - config, - props, -) => { +const withIapAndroid: ConfigPlugin< + | { + iapkitApiKey?: string; + isHorizonEnabled?: boolean; + isFireOsEnabled?: boolean; + } + | undefined +> = (config, props) => { // Add OpenIAP dependency to app build.gradle config = withAppBuildGradle(config, (config) => { config.modResults.contents = modifyAppBuildGradle( config.modResults.contents, + props?.isHorizonEnabled, + props?.isFireOsEnabled, + ); + return config; + }); + + 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.push({ + type: 'property', + key: 'horizonEnabled', + value: String(horizonValue), + }); + config.modResults.push({ + type: 'property', + key: 'fireOsEnabled', + value: String(fireOsValue), + }); + return config; }); config = withAndroidManifest(config, (config) => { const manifest = config.modResults; - if (!manifest.manifest['uses-permission']) { - manifest.manifest['uses-permission'] = []; - } - - const permissions = manifest.manifest['uses-permission']; + const existingPermissions = manifest.manifest['uses-permission']; + const permissions = Array.isArray(existingPermissions) + ? existingPermissions + : existingPermissions + ? [existingPermissions] + : []; + manifest.manifest['uses-permission'] = permissions; const billingPerm = {$: {'android:name': 'com.android.vending.BILLING'}}; - const alreadyExists = permissions.some( - (p) => p.$['android:name'] === 'com.android.vending.BILLING', - ); - if (!alreadyExists) { - permissions.push(billingPerm); - if (!hasLoggedPluginExecution) { - console.log( - '✅ Added com.android.vending.BILLING to AndroidManifest.xml', - ); - } + if (props?.isFireOsEnabled) { + manifest.manifest['uses-permission'] = permissions.filter( + (p) => p.$['android:name'] !== 'com.android.vending.BILLING', + ); } else { - if (!hasLoggedPluginExecution) { - console.log( - 'ℹ️ com.android.vending.BILLING already exists in AndroidManifest.xml', - ); + const alreadyExists = permissions.some( + (p) => p.$['android:name'] === 'com.android.vending.BILLING', + ); + if (!alreadyExists) { + permissions.push(billingPerm); + if (!hasLoggedPluginExecution) { + console.log( + '✅ Added com.android.vending.BILLING to AndroidManifest.xml', + ); + } + } else { + if (!hasLoggedPluginExecution) { + console.log( + 'ℹ️ com.android.vending.BILLING already exists in AndroidManifest.xml', + ); + } } } @@ -366,8 +443,54 @@ type IapPluginProps = { * Get your project key from https://kit.openiap.dev */ iapkitApiKey?: string; + /** + * Amazon platform targets. + * + * Fire OS selects the Android Amazon Appstore flavor. Vega OS is selected by + * the Kepler runtime and does not change the Android Gradle flavor. + */ + amazon?: { + /** + * Fire OS module for Amazon-distributed Android builds. + * @platform android + */ + fireOS?: boolean; + /** + * Vega OS runtime target. This is not an Android flavor. + */ + vegaOS?: boolean; + }; + /** + * Optional non-Amazon Android store modules. + * Fire OS takes precedence when both Fire OS and Horizon are enabled. + */ + modules?: { + /** + * Horizon module for Meta Quest/VR devices. + * @platform android + */ + horizon?: boolean; + }; }; +export function resolveAmazonPlatformFlags(props?: IapPluginProps): { + isFireOsEnabled: boolean; + isVegaEnabled: boolean; + isHorizonEnabled: boolean; +} { + const isFireOsEnabled = props?.amazon?.fireOS ?? false; + const isVegaEnabled = props?.amazon?.vegaOS ?? false; + const isHorizonEnabled = isFireOsEnabled + ? false + : (props?.modules?.horizon ?? false); + + return { + isFireOsEnabled, + isVegaEnabled, + isHorizonEnabled, + }; +} + const withIapIosFollyWorkaround: ConfigPlugin = ( config, props, @@ -452,7 +575,14 @@ const withIapkitApiKeyIOS: ConfigPlugin = ( const withIAP: ConfigPlugin = (config, props) => { try { - let result = withIapAndroid(config, {iapkitApiKey: props?.iapkitApiKey}); + const {isFireOsEnabled, isHorizonEnabled} = + resolveAmazonPlatformFlags(props); + + let result = withIapAndroid(config, { + iapkitApiKey: props?.iapkitApiKey, + isHorizonEnabled, + isFireOsEnabled, + }); result = withIapIosFollyWorkaround(result, props); // Add iOS alternative billing configuration if provided if (props?.iosAlternativeBilling) { diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 580e79b6..a20eb44c 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -100,6 +100,8 @@ describe('Public API (src/index.ts)', () => { (Platform as any).OS = 'ios'; // Re-require module to ensure fresh state if needed jest.resetModules(); + jest.dontMock('react-native'); + jest.dontMock('../vega'); // Reinstall the NitroModules mock after reset jest.doMock('react-native-nitro-modules', () => ({ NitroModules: { @@ -110,6 +112,10 @@ describe('Public API (src/index.ts)', () => { mockIap.getReceiptIOS = undefined; mockIap.requestReceiptRefreshIOS = undefined; mockIap.getStorefront = jest.fn(async () => 'USA'); + mockIap.addSubscriptionBillingIssueListener.mockReset(); + mockIap.addSubscriptionBillingIssueListener.mockImplementation( + () => undefined, + ); // Ensure getAvailablePurchases always returns an empty array by default mockIap.getAvailablePurchases = jest.fn(async () => []); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -928,6 +934,50 @@ describe('Public API (src/index.ts)', () => { expect(res.map((p: any) => p.productId).sort()).toEqual(['p1', 's1']); }); + it('Vega path queries purchase updates once', async () => { + jest.resetModules(); + jest.doMock('react-native', () => ({ + Platform: {OS: 'kepler'}, + })); + jest.doMock('react-native-nitro-modules', () => ({ + NitroModules: { + createHybridObject: jest.fn(() => mockIap), + }, + })); + jest.doMock('../vega', () => ({ + getVegaIapModule: jest.fn(() => mockIap), + isVegaOS: jest.fn(() => true), + })); + // eslint-disable-next-line @typescript-eslint/no-var-requires + IAP = require('../index'); + + const nitro = { + id: 't-vega', + productId: 'premium_monthly', + transactionDate: Date.now(), + platform: 'android', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: true, + }; + mockIap.getAvailablePurchases.mockResolvedValueOnce([nitro]); + + const res = await IAP.getAvailablePurchases({ + includeSuspendedAndroid: true, + }); + + expect(mockIap.getAvailablePurchases).toHaveBeenCalledTimes(1); + expect(mockIap.getAvailablePurchases).toHaveBeenCalledWith({ + android: {includeSuspended: true}, + }); + expect(res).toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + platform: 'android', + }), + ]); + }); + it('throws on unsupported platform', async () => { (Platform as any).OS = 'web'; await expect(IAP.getAvailablePurchases()).rejects.toThrow( @@ -1699,6 +1749,44 @@ describe('Public API (src/index.ts)', () => { expect(result.iapkit?.store).toBe('google'); }); + it('should pass Amazon IAPKit payloads through on Android', async () => { + (Platform as any).OS = 'android'; + const mockResult = { + provider: 'iapkit', + iapkit: { + isValid: true, + state: 'ready-to-consume', + store: 'amazon', + }, + }; + mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce(mockResult); + + const result = await IAP.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + amazon: { + userId: 'amazon-user', + receiptId: 'amazon-receipt', + sandbox: true, + }, + }, + }); + + expect(mockIap.verifyPurchaseWithProvider).toHaveBeenCalledWith({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + amazon: { + userId: 'amazon-user', + receiptId: 'amazon-receipt', + sandbox: true, + }, + }, + }); + expect(result.iapkit?.store).toBe('amazon'); + }); + it('should throw error when provider is not iapkit', async () => { (Platform as any).OS = 'ios'; const mockResult = { @@ -2398,6 +2486,27 @@ describe('Public API (src/index.ts)', () => { expect(sub2).toBeDefined(); }); + it('reattaches a pre-init listener after initConnection', async () => { + mockIap.addSubscriptionBillingIssueListener + .mockImplementationOnce(() => { + throw new Error('Nitro runtime not installed'); + }) + .mockImplementation(() => undefined); + + const handler = jest.fn(); + const sub = IAP.subscriptionBillingIssueListener(handler); + expect(mockIap.addSubscriptionBillingIssueListener).toHaveBeenCalledTimes( + 1, + ); + + await IAP.initConnection(); + + expect(mockIap.addSubscriptionBillingIssueListener).toHaveBeenCalledTimes( + 2, + ); + sub.remove(); + }); + it('cleans up JS listener when native attach throws', () => { mockIap.addSubscriptionBillingIssueListener.mockImplementation(() => { throw new Error('native failure'); diff --git a/libraries/react-native-iap/src/__tests__/utils/error.test.ts b/libraries/react-native-iap/src/__tests__/utils/error.test.ts index 30b72cc9..44c93733 100644 --- a/libraries/react-native-iap/src/__tests__/utils/error.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/error.test.ts @@ -47,6 +47,29 @@ describe('Error utilities', () => { }); }); + it('should preserve structured fields on Error objects', () => { + const error = new Error('Network error') as Error & { + code?: ErrorCode; + debugMessage?: string; + productId?: string; + responseCode?: number; + }; + error.code = ErrorCode.NetworkError; + error.debugMessage = 'Network error'; + error.productId = 'premium_monthly'; + error.responseCode = 503; + + const result = parseErrorStringToJsonObj(error); + + expect(result).toEqual({ + code: ErrorCode.NetworkError, + message: 'Network error', + debugMessage: 'Network error', + productId: 'premium_monthly', + responseCode: 503, + }); + }); + it('should handle non-JSON string', () => { const errorString = 'Not a JSON string'; 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 624629d2..b569acfb 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 @@ -371,6 +371,23 @@ describe('type-bridge utilities', () => { expect(result.purchaseState).toBe('purchased'); expect(result.autoRenewingAndroid).toBe(true); }); + + it('preserves Amazon store on Android purchases', () => { + const nitroPurchase: NitroPurchase = { + id: 'receipt-amazon', + productId: 'sku-amazon', + transactionDate: 789, + purchaseTokenAndroid: 'receipt-amazon', + platform: 'android', + store: 'amazon', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + } as NitroPurchase; + + const result = convertNitroPurchaseToPurchase(nitroPurchase); + expect(result.store).toBe('amazon'); + }); }); describe('validation helpers', () => { @@ -383,12 +400,25 @@ describe('type-bridge utilities', () => { platform: 'ios', } as NitroProduct); - const invalid = validateNitroProduct({ - title: 'missing fields', - } as NitroProduct); - - expect(valid).toBe(true); - expect(invalid).toBe(false); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const invalid = validateNitroProduct({ + title: 'missing fields', + } as NitroProduct); + + expect(valid).toBe(true); + expect(invalid).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[RN-IAP]', + 'NitroProduct missing required field: id', + {title: 'missing fields'}, + ); + } finally { + consoleErrorSpy.mockRestore(); + } }); it('validates NitroPurchase shape', () => { @@ -399,12 +429,25 @@ describe('type-bridge utilities', () => { platform: 'ios', } as NitroPurchase); - const invalid = validateNitroPurchase({ - productId: 'sku', - } as NitroPurchase); - - expect(valid).toBe(true); - expect(invalid).toBe(false); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const invalid = validateNitroPurchase({ + productId: 'sku', + } as NitroPurchase); + + expect(valid).toBe(true); + expect(invalid).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[RN-IAP]', + 'NitroPurchase missing required field: id', + {productId: 'sku'}, + ); + } finally { + consoleErrorSpy.mockRestore(); + } }); }); diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts new file mode 100644 index 00000000..59637e05 --- /dev/null +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -0,0 +1,1473 @@ +import {createVegaIapModule, type VegaPurchasingService} from '../vega-adapter'; +import {ErrorCode} from '../types'; + +const createService = (): jest.Mocked => + ({ + getUserData: jest.fn(async () => ({ + responseCode: 1, + userData: { + countryCode: 'US', + marketplace: 'US', + userId: 'amazon-user', + }, + })), + getProductData: jest.fn(async () => ({ + responseCode: 1, + productData: new Map([ + [ + 'coins_100', + { + sku: 'coins_100', + title: '100 Coins', + description: 'Coin pack', + productType: 1, + price: { + priceCurrencyCode: 'USD', + priceStr: '$0.99', + valueInMicros: 990000, + }, + }, + ], + [ + 'premium_monthly', + { + sku: 'premium_monthly', + title: 'Premium Monthly', + description: 'Monthly plan', + productType: 3, + subscriptionPeriod: 'P1M', + price: { + priceCurrencyCode: 'USD', + priceStr: '$4.99', + valueInMicros: 4990000, + }, + }, + ], + ]), + })), + purchase: jest.fn(async () => ({ + responseCode: 0, + receipt: { + receiptId: 'receipt-1', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-05-11T00:00:00.000Z'), + }, + })), + getPurchaseUpdates: jest.fn(async () => ({ + responseCode: 1, + receiptList: [ + { + receiptId: 'sub-receipt', + sku: 'premium_monthly', + productType: 3, + purchaseDate: new Date('2026-05-10T00:00:00.000Z'), + }, + ], + })), + notifyFulfillment: jest.fn(async () => ({ + responseCode: 1, + })), + }) as unknown as jest.Mocked; + +describe('Amazon Vega adapter', () => { + it('initializes without fetching Amazon user data', async () => { + const service = createService(); + const module = createVegaIapModule(service); + + await expect(module.initConnection()).resolves.toBe(true); + + expect(service.getUserData).not.toHaveBeenCalled(); + }); + + it('maps Vega products to Nitro Android products', async () => { + const service = createService(); + const module = createVegaIapModule(service); + + const products = await module.fetchProducts( + ['coins_100', 'premium_monthly'], + 'all', + ); + + expect(service.getProductData).toHaveBeenCalledWith({ + skus: ['coins_100', 'premium_monthly'], + }); + expect(products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'coins_100', + type: 'in-app', + platform: 'android', + }), + expect.objectContaining({ + id: 'premium_monthly', + type: 'subs', + platform: 'android', + subscriptionOfferDetailsAndroid: expect.any(String), + }), + ]), + ); + }); + + it('accepts Amazon Vega string success response codes', async () => { + const service = createService(); + service.getProductData.mockResolvedValueOnce({ + responseCode: 'SUCCESSFUL', + productData: { + coins_100: { + sku: 'coins_100', + title: '100 Coins', + description: 'Coin pack', + productType: 1, + price: { + priceCurrencyCode: 'USD', + priceStr: '$0.99', + valueInMicros: '990000', + }, + }, + }, + }); + const module = createVegaIapModule(service); + + await expect(module.fetchProducts(['coins_100'], 'all')).resolves.toEqual([ + expect.objectContaining({ + id: 'coins_100', + price: 0.99, + }), + ]); + }); + + it('maps App Tester catalog-shaped product data', async () => { + const service = createService(); + service.getProductData.mockResolvedValueOnce({ + responseCode: 1, + productData: { + 'dev.hyo.martie.10bulbs': { + itemType: 'CONSUMABLE', + price: 0.99, + title: '10 Bulbs', + description: 'A small pack of bulbs', + }, + 'dev.hyo.martie.premium': { + itemType: 'SUBSCRIPTION', + price: 4.99, + term: 'Monthly', + title: 'Premium Monthly', + description: 'Monthly premium access', + }, + }, + }); + const module = createVegaIapModule(service); + + await expect( + module.fetchProducts( + ['dev.hyo.martie.10bulbs', 'dev.hyo.martie.premium'], + 'all', + ), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'dev.hyo.martie.10bulbs', + type: 'in-app', + price: 0.99, + }), + expect.objectContaining({ + id: 'dev.hyo.martie.premium', + type: 'subs', + price: 4.99, + subscriptionPeriodAndroid: 'P1M', + }), + ]), + ); + }); + + it('emits a purchase update and finishes with notifyFulfillment', async () => { + const service = createService(); + const module = createVegaIapModule(service); + const listener = jest.fn(); + + module.addPurchaseUpdatedListener(listener); + const result = await module.requestPurchase({ + android: {skus: ['coins_100']}, + }); + + expect(result).toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'receipt-1', + store: 'amazon', + }), + ]); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'receipt-1', + }), + ); + + await expect( + module.finishTransaction({ + android: {purchaseToken: 'receipt-1', isConsumable: true}, + }), + ).resolves.toEqual( + expect.objectContaining({ + responseCode: 0, + purchaseToken: 'receipt-1', + }), + ); + expect(service.notifyFulfillment).toHaveBeenCalledWith({ + fulfillmentResult: 1, + receiptId: 'receipt-1', + }); + }); + + it('retries transient Amazon Vega fulfillment failures', async () => { + jest.useFakeTimers(); + const service = createService(); + service.notifyFulfillment + .mockResolvedValueOnce({responseCode: 'FAILED'}) + .mockResolvedValueOnce({responseCode: 1}); + const module = createVegaIapModule(service); + + try { + const result = module.finishTransaction({ + android: {purchaseToken: 'receipt-1', isConsumable: true}, + }); + await Promise.resolve(); + jest.advanceTimersByTime(1_000); + + await expect(result).resolves.toEqual( + expect.objectContaining({ + responseCode: 0, + purchaseToken: 'receipt-1', + }), + ); + expect(service.notifyFulfillment).toHaveBeenCalledTimes(2); + expect(service.notifyFulfillment).toHaveBeenNthCalledWith(2, { + fulfillmentResult: 1, + receiptId: 'receipt-1', + }); + } finally { + jest.useRealTimers(); + } + }); + + it('recovers fulfillable receipts after Amazon Vega purchase failures', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 'FAILED', + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'recovered-receipt', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + const listener = jest.fn(); + const errorListener = jest.fn(); + module.addPurchaseUpdatedListener(listener); + module.addPurchaseErrorListener(errorListener); + + await expect( + module.requestPurchase({ + android: {skus: ['coins_100']}, + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + store: 'amazon', + }), + ]); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + }), + ); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(errorListener).not.toHaveBeenCalled(); + }); + + it('recovers fulfillable receipts after parser-only purchase errors', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-10T00:00:00.000Z')); + const service = createService(); + try { + service.purchase.mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'recovered-receipt', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-06-10T00:00:01.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + const listener = jest.fn(); + const errorListener = jest.fn(); + module.addPurchaseUpdatedListener(listener); + module.addPurchaseErrorListener(errorListener); + + await expect( + module.requestPurchase({ + android: {skus: ['coins_100']}, + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + store: 'amazon', + }), + ]); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + }), + ); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(errorListener).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('does not recover old receipts after parser-only purchase errors', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-10T00:00:00.000Z')); + const service = createService(); + const parserError = new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ); + try { + service.purchase.mockRejectedValueOnce(parserError); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'old-receipt', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-06-09T23:00:00.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + const listener = jest.fn(); + const errorListener = jest.fn(); + module.addPurchaseUpdatedListener(listener); + module.addPurchaseErrorListener(errorListener); + + await expect( + module.requestPurchase({ + android: {skus: ['coins_100']}, + }), + ).rejects.toBe(parserError); + expect(listener).not.toHaveBeenCalled(); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.PurchaseError, + }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('does not fulfill recovered purchases before the app finishes them', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 'FAILED', + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'recovered-receipt', + sku: 'coins_100', + productType: 1, + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + + await expect( + module.requestPurchase({ + android: {skus: ['coins_100']}, + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + }), + ]); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + }); + + it('emits other recovered receipts while preserving the original purchase failure', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 'FAILED', + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'previous-sub-receipt', + sku: 'premium_monthly', + productType: 3, + purchaseDate: new Date('2026-06-09T00:00:00.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + const listener = jest.fn(); + module.addPurchaseUpdatedListener(listener); + + await expect( + module.requestPurchase({ + android: {skus: ['coins_100']}, + }), + ).rejects.toMatchObject({ + code: ErrorCode.UserCancelled, + }); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(service.purchase).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'premium_monthly', + purchaseToken: 'previous-sub-receipt', + }), + ); + }); + + it('treats subscription base receipts as the requested subscription purchase', async () => { + const service = createService(); + service.getProductData.mockResolvedValueOnce({ + responseCode: 1, + productData: new Map([ + [ + 'premium_monthly', + { + sku: 'premium_monthly', + title: 'Premium Monthly', + description: 'Monthly plan', + productType: 3, + subscriptionBase: 'premium_monthly.base', + price: { + priceCurrencyCode: 'USD', + priceStr: '$4.99', + valueInMicros: 4990000, + }, + }, + ], + ]), + }); + service.purchase.mockResolvedValueOnce({ + responseCode: 4, + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'base-receipt', + sku: 'premium_monthly.base', + productType: 3, + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + + await module.fetchProducts(['premium_monthly'], 'subs'); + + await expect( + module.requestPurchase({ + android: { + skus: ['premium_monthly'], + subscriptionOffers: [ + {sku: 'premium_monthly', offerToken: 'offer-token'}, + ], + }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + purchaseToken: 'base-receipt', + }), + ]); + expect(service.notifyFulfillment).not.toHaveBeenCalled(); + expect(service.purchase).toHaveBeenCalledTimes(1); + }); + + it('normalizes subscription base receipts in active subscription queries', async () => { + const service = createService(); + service.getProductData.mockResolvedValueOnce({ + responseCode: 1, + productData: new Map([ + [ + 'premium_monthly', + { + sku: 'premium_monthly', + title: 'Premium Monthly', + description: 'Monthly plan', + productType: 3, + subscriptionBase: 'premium_monthly.base', + price: { + priceCurrencyCode: 'USD', + priceStr: '$4.99', + valueInMicros: 4990000, + }, + }, + ], + ]), + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'base-receipt', + sku: 'premium_monthly.base', + productType: 3, + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + + await module.fetchProducts(['premium_monthly'], 'subs'); + + await expect( + module.getActiveSubscriptions(['premium_monthly']), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + basePlanIdAndroid: 'premium_monthly', + currentPlanId: 'premium_monthly', + purchaseToken: 'base-receipt', + }), + ]); + }); + + it('keeps original purchase failure when recovery parsing fails', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 'FAILED', + receipt: null, + }); + service.getPurchaseUpdates.mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + const module = createVegaIapModule(service); + const errorListener = jest.fn(); + module.addPurchaseErrorListener(errorListener); + + await expect( + module.requestPurchase({ + android: {skus: ['coins_100']}, + }), + ).rejects.toMatchObject({ + code: ErrorCode.UserCancelled, + }); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.UserCancelled, + }), + ); + }); + + it('maps Amazon invalid SKU purchase failures to OpenIAP errors', async () => { + const service = createService(); + service.purchase.mockResolvedValue({ + responseCode: 2, + receipt: null, + }); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [], + }); + const module = createVegaIapModule(service); + const errorListener = jest.fn(); + module.addPurchaseErrorListener(errorListener); + + await expect( + module.requestPurchase({ + android: {skus: ['missing_sku']}, + }), + ).rejects.toMatchObject({ + code: ErrorCode.SkuNotFound, + responseCode: 2, + }); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.SkuNotFound, + responseCode: 2, + }), + ); + }); + + it('returns active subscriptions from purchase updates', async () => { + const service = createService(); + const module = createVegaIapModule(service); + + const subscriptions = await module.getActiveSubscriptions([ + 'premium_monthly', + ]); + + expect(service.getPurchaseUpdates).toHaveBeenCalledWith({reset: true}); + expect(subscriptions).toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isActive: true, + basePlanIdAndroid: 'premium_monthly', + currentPlanId: 'premium_monthly', + purchaseToken: 'sub-receipt', + }), + ]); + }); + + it('uses cached product types when purchase updates omit productType', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: [ + { + receiptId: 'sub-receipt', + sku: 'premium_monthly', + purchaseDate: new Date('2026-05-10T00:00:00.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + + await module.fetchProducts(['premium_monthly'], 'subs'); + + await expect( + module.getActiveSubscriptions(['premium_monthly']), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isActive: true, + basePlanIdAndroid: 'premium_monthly', + currentPlanId: 'premium_monthly', + purchaseToken: 'sub-receipt', + }), + ]); + }); + + it('hydrates product types when purchase updates omit productType before fetchProducts', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: [ + { + receiptId: 'sub-receipt', + sku: 'premium_monthly', + purchaseDate: new Date('2026-05-10T00:00:00.000Z'), + }, + ], + }); + const module = createVegaIapModule(service); + + await expect( + module.getActiveSubscriptions(['premium_monthly']), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isActive: true, + basePlanIdAndroid: 'premium_monthly', + currentPlanId: 'premium_monthly', + purchaseToken: 'sub-receipt', + }), + ]); + expect(service.getProductData).toHaveBeenCalledWith({ + skus: ['premium_monthly'], + }); + }); + + it('uses subscription request context when purchase receipts omit productType', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 0, + receipt: { + receiptId: 'sub-purchase', + sku: 'premium_monthly', + }, + }); + const module = createVegaIapModule(service); + + await expect( + module.requestPurchase({ + android: {skus: ['premium_monthly'], subscriptionOffers: []}, + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isAutoRenewing: true, + autoRenewingAndroid: true, + }), + ]); + }); + + it('uses serialized subscription request context for direct Nitro calls', async () => { + const service = createService(); + service.purchase.mockResolvedValueOnce({ + responseCode: 0, + receipt: { + receiptId: 'sub-purchase', + sku: 'premium_monthly', + }, + }); + const module = createVegaIapModule(service); + + await expect( + module.requestPurchase({ + android: { + skus: ['premium_monthly'], + subscriptionOffers: '[]' as any, + }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + productId: 'premium_monthly', + isAutoRenewing: true, + autoRenewingAndroid: true, + }), + ]); + }); + + it('exposes direct finish helpers and unsupported stubs on Vega', async () => { + const service = createService(); + const module = createVegaIapModule(service) as ReturnType< + typeof createVegaIapModule + > & { + acknowledgePurchaseAndroid(purchaseToken: string): Promise; + addSubscriptionBillingIssueListener(listener?: unknown): void; + consumePurchaseAndroid(purchaseToken: string): Promise; + deepLinkToSubscriptionsAndroid(options: unknown): Promise; + restorePurchases(): Promise; + }; + + await expect(module.acknowledgePurchaseAndroid('receipt-1')).resolves.toBe( + true, + ); + await expect(module.consumePurchaseAndroid('receipt-2')).resolves.toBe( + true, + ); + const listener = jest.fn(); + module.addPurchaseUpdatedListener(listener); + await expect(module.restorePurchases()).resolves.toBeUndefined(); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'premium_monthly', + purchaseToken: 'sub-receipt', + }), + ); + expect(module.addSubscriptionBillingIssueListener).not.toThrow(); + await expect( + module.deepLinkToSubscriptionsAndroid({ + packageNameAndroid: 'dev.hyo.openiap', + skuAndroid: 'premium_monthly', + }), + ).rejects.toMatchObject({ + code: ErrorCode.FeatureNotSupported, + }); + expect(service.notifyFulfillment).toHaveBeenCalledWith({ + fulfillmentResult: 1, + receiptId: 'receipt-1', + }); + expect(service.notifyFulfillment).toHaveBeenCalledWith({ + fulfillmentResult: 1, + receiptId: 'receipt-2', + }); + }); + + it('loads all paginated Amazon purchase updates', async () => { + const service = createService(); + service.getPurchaseUpdates + .mockResolvedValueOnce({ + responseCode: 1, + hasMore: true, + receiptList: [ + { + receiptId: 'receipt-page-1', + sku: 'coins_100', + productType: 1, + }, + ], + }) + .mockResolvedValueOnce({ + responseCode: 1, + hasMore: false, + receiptList: [ + { + receiptId: 'receipt-page-2', + sku: 'premium_monthly', + productType: 3, + }, + ], + }); + const module = createVegaIapModule(service); + + const purchases = await module.getAvailablePurchases(); + + expect(service.getPurchaseUpdates).toHaveBeenNthCalledWith(1, { + reset: true, + }); + expect(service.getPurchaseUpdates).toHaveBeenNthCalledWith(2, { + reset: false, + }); + expect(purchases.map((purchase) => purchase.id)).toEqual([ + 'receipt-page-1', + 'receipt-page-2', + ]); + }); + + it('treats Amazon parser-only purchase update errors as no updates', async () => { + const service = createService(); + service.getPurchaseUpdates.mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + const module = createVegaIapModule(service); + + await expect(module.getAvailablePurchases()).resolves.toEqual([]); + }); + + it('retries failed Amazon purchase update responses', async () => { + jest.useFakeTimers(); + const service = createService(); + service.getPurchaseUpdates + .mockResolvedValueOnce({ + responseCode: 3, + receiptList: [], + }) + .mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'recovered-receipt', + sku: 'coins_100', + productType: 1, + }, + ], + }); + const module = createVegaIapModule(service); + + try { + const result = module.getAvailablePurchases(); + await Promise.resolve(); + jest.advanceTimersByTime(1_000); + + await expect(result).resolves.toEqual([ + expect.objectContaining({ + productId: 'coins_100', + purchaseToken: 'recovered-receipt', + }), + ]); + expect(service.getPurchaseUpdates).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('ignores parser-only product type hydration errors for purchase updates', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValueOnce({ + responseCode: 1, + receiptList: [ + { + receiptId: 'base-receipt', + sku: 'premium_monthly.base', + purchaseDate: new Date('2026-06-10T00:00:00.000Z'), + }, + ], + }); + service.getProductData.mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + const module = createVegaIapModule(service); + + await expect( + module.getActiveSubscriptions(['premium_monthly']), + ).resolves.toEqual([]); + }); + + it('chunks Vega product data requests', async () => { + const service = createService(); + const skus = Array.from({length: 101}, (_, index) => `sku_${index}`); + service.getProductData.mockImplementation(async ({skus: batch}) => ({ + responseCode: 1, + productData: Object.fromEntries( + batch.map((sku) => [ + sku, + { + sku, + title: sku, + description: 'Product', + productType: 1, + price: { + priceCurrencyCode: 'USD', + priceStr: '$0.99', + valueInMicros: 990000, + }, + }, + ]), + ), + })); + const module = createVegaIapModule(service); + + const products = await module.fetchProducts(skus, 'all'); + + expect(products).toHaveLength(101); + expect(service.getProductData).toHaveBeenCalledTimes(2); + expect(service.getProductData.mock.calls[0]?.[0].skus).toHaveLength(100); + expect(service.getProductData.mock.calls[1]?.[0].skus).toHaveLength(1); + }); + + it('chunks product type hydration for purchase updates', async () => { + const service = createService(); + const skus = Array.from({length: 101}, (_, index) => `sub_${index}`); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: skus.map((sku) => ({ + receiptId: `receipt_${sku}`, + sku, + })), + }); + service.getProductData.mockImplementation(async ({skus: batch}) => ({ + responseCode: 1, + productData: Object.fromEntries( + batch.map((sku) => [ + sku, + { + sku, + title: sku, + description: 'Subscription', + productType: 3, + subscriptionPeriod: 'P1M', + price: { + priceCurrencyCode: 'USD', + priceStr: '$4.99', + valueInMicros: 4990000, + }, + }, + ]), + ), + })); + const module = createVegaIapModule(service); + + const purchases = await module.getAvailablePurchases(); + + expect(purchases).toHaveLength(101); + expect(purchases[0]).toMatchObject({ + isAutoRenewing: true, + productId: 'sub_0', + }); + expect(service.getProductData).toHaveBeenCalledTimes(2); + expect(service.getProductData.mock.calls[0]?.[0].skus).toHaveLength(100); + expect(service.getProductData.mock.calls[1]?.[0].skus).toHaveLength(1); + }); + + it('excludes suspended purchases unless requested', async () => { + const service = createService(); + service.getPurchaseUpdates.mockResolvedValue({ + responseCode: 1, + receiptList: [ + { + receiptId: 'deferred-sub', + sku: 'premium_monthly', + productType: 3, + isDeferred: true, + }, + ], + }); + const module = createVegaIapModule(service); + + await expect( + module.getAvailablePurchases({ + android: {type: 'subs', includeSuspended: false}, + }), + ).resolves.toEqual([]); + + await expect( + module.getAvailablePurchases({ + android: {type: 'subs', includeSuspended: true}, + }), + ).resolves.toEqual([ + expect.objectContaining({ + id: 'deferred-sub', + isAutoRenewing: false, + isSuspendedAndroid: true, + purchaseState: 'pending', + }), + ]); + }); + + it('verifies Vega receipts through IAPKit Amazon payload', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json({ + isValid: true, + state: 'READY_TO_CONSUME', + store: 'amazon', + }), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: 'kit-key', + amazon: { + receiptId: 'receipt-vega-1', + sandbox: true, + }, + }, + }), + ).resolves.toEqual({ + provider: 'iapkit', + iapkit: { + isValid: true, + state: 'ready-to-consume', + store: 'amazon', + }, + }); + + expect(service.getUserData).toHaveBeenCalledWith({ + fetchUserProfileAccessConsentStatus: false, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://kit.openiap.dev/v1/purchase/verify', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer kit-key', + 'Content-Type': 'application/json', + }), + }), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + store: 'amazon', + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + sandbox: true, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('supports custom IAPKit base URLs for Vega verification', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json({ + isValid: true, + state: 'ENTITLED', + store: 'amazon', + }), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: 'kit-key', + baseUrl: 'http://localhost:3100/', + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + } as Parameters[0] & { + iapkit: {baseUrl: string}; + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3100/v1/purchase/verify', + expect.any(Object), + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects mixed IAPKit payloads on the Amazon Vega adapter', async () => { + const service = createService(); + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + google: { + purchaseToken: 'google-token', + }, + }, + }), + ).rejects.toThrow( + 'Amazon Vega IAPKit verification requires exactly one amazon payload.', + ); + }); + + it('wraps non-JSON IAPKit failures as receipt errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response('bad gateway', {status: 502}), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('HTTP 502'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('extracts nested JSON IAPKit failure messages', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + message: JSON.stringify({ + error: 'receipt no longer valid', + }), + }, + {status: 400}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('receipt no longer valid'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('extracts string entries from IAPKit error arrays', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + errors: ['receipt array failure'], + }, + {status: 400}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('receipt array failure'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects empty successful IAPKit responses as receipt errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response('', {status: 200}), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('IAPKit returned non-JSON response (HTTP 200).'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('treats successful IAPKit error payloads as receipt errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + errors: [ + { + code: 'BAD_RECEIPT', + message: 'bad receipt', + }, + ], + }, + {status: 200}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('bad receipt'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects malformed successful IAPKit payloads', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json(['not', 'an', 'object'], {status: 200}), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('IAPKit returned malformed response (HTTP 200).'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects successful IAPKit payloads missing required fields', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + state: 'ENTITLED', + store: 'amazon', + }, + {status: 200}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('IAPKit returned malformed response (HTTP 200).'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('rejects successful IAPKit payloads for another store', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Response.json( + { + isValid: true, + state: 'ENTITLED', + store: 'apple', + }, + {status: 200}, + ), + ) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toThrow('IAPKit returned malformed response (HTTP 200).'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('wraps IAPKit network failures as network errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn(async () => { + throw new TypeError('network offline'); + }) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.NetworkError, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('wraps IAPKit response body read failures as network errors', async () => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn(async () => ({ + ok: true, + status: 200, + text: async () => { + throw new TypeError('body stream failed'); + }, + })) as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.NetworkError, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/libraries/react-native-iap/src/index.kepler.ts b/libraries/react-native-iap/src/index.kepler.ts new file mode 100644 index 00000000..9e02e91e --- /dev/null +++ b/libraries/react-native-iap/src/index.kepler.ts @@ -0,0 +1,311 @@ +import {getVegaIapModule} from './vega'; +import type { + MutationField, + Product, + ProductQueryType, + ProductSubscription, + Purchase, + PurchaseError, + PurchaseUpdatedListenerOptions, + QueryField, + RequestPurchasePropsByPlatforms, + RequestSubscriptionPropsByPlatforms, +} from './types'; +import type { + NitroAvailablePurchasesOptions, + NitroFinishTransactionParams, + NitroProduct, + NitroPurchase, + NitroPurchaseRequest, +} from './specs/RnIap.nitro'; +import { + convertNitroProductToProduct, + convertNitroPurchaseToPurchase, + convertProductToProductSubscription, + validateNitroProduct, + validateNitroPurchase, +} from './utils/type-bridge'; + +export * from './types'; +export * from './utils/error'; +export * from './vega'; +export {useIAP, type UseIapOptions} from './hooks/useIAP'; +export {connectWebhookStream, parseWebhookEventData} from './webhook-client'; +export {kitApi, KitApiError} from './kit-api'; + +export type ProductTypeInput = 'inapp' | 'in-app' | 'subs'; + +export interface EventSubscription { + remove(): void; +} + +type VegaModuleExtras = { + acknowledgePurchaseAndroid(purchaseToken: string): Promise; + consumePurchaseAndroid(purchaseToken: string): Promise; + restorePurchases?: () => Promise; +}; + +const unsupported = (feature: string): never => { + throw new Error(`${feature} is not supported on Amazon Vega.`); +}; + +const getModule = () => { + const module = getVegaIapModule(); + if (!module) { + throw new Error( + 'Amazon Vega IAP module is unavailable. Install @amazon-devices/keplerscript-appstore-iap-lib in the Vega app target and build with the React Native for Vega kepler platform.', + ); + } + return module; +}; + +const getVegaModule = () => + getModule() as ReturnType & VegaModuleExtras; + +const normalizeProductQueryType = ( + type?: ProductQueryType | ProductTypeInput | null, +): ProductTypeInput | 'all' => { + if (type === 'subs') return 'subs'; + if (type === 'all') return 'all'; + return 'inapp'; +}; + +const mapProducts = ( + nitroProducts: NitroProduct[], + type: ProductTypeInput | 'all', +) => { + const converted = nitroProducts + .filter(validateNitroProduct) + .map(convertNitroProductToProduct); + + if (type === 'subs') { + return converted.map(convertProductToProductSubscription); + } + + return converted; +}; + +export const isNitroReady = (): boolean => false; +export const isTVOS = (): boolean => false; +export const isMacOS = (): boolean => false; +export const isStandardIOS = (): boolean => false; + +export const initConnection: MutationField<'initConnection'> = async ( + config, +) => { + return getModule().initConnection(config ?? null); +}; + +export const endConnection: MutationField<'endConnection'> = async () => { + return getModule().endConnection(); +}; + +export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { + const {skus, type} = request; + const normalizedType = normalizeProductQueryType(type); + const nitroProducts = await getModule().fetchProducts(skus, normalizedType); + return mapProducts(nitroProducts, normalizedType) as + | Product[] + | ProductSubscription[]; +}; + +export const requestPurchase: MutationField<'requestPurchase'> = async ( + request, +) => { + const perPlatformRequest = request.request as + | RequestPurchasePropsByPlatforms + | RequestSubscriptionPropsByPlatforms + | undefined; + const androidRequest = + perPlatformRequest?.google ?? perPlatformRequest?.android; + + if (!androidRequest?.skus?.length) { + throw new Error( + 'Invalid request for Amazon Vega. The `request.google.skus` or `request.android.skus` property is required and must be a non-empty array.', + ); + } + + const nitroRequest: NitroPurchaseRequest = { + google: androidRequest, + android: androidRequest, + }; + const result = await getModule().requestPurchase(nitroRequest); + if (!Array.isArray(result)) return null; + const purchases = result as unknown as NitroPurchase[]; + return purchases + .filter(validateNitroPurchase) + .map((purchase) => convertNitroPurchaseToPurchase(purchase)); +}; + +export const getAvailablePurchases: QueryField< + 'getAvailablePurchases' +> = async (options) => { + const nitroOptions: NitroAvailablePurchasesOptions = { + android: { + includeSuspended: options?.includeSuspendedAndroid ?? false, + }, + }; + return getModule() + .getAvailablePurchases(nitroOptions) + .then((purchases) => + purchases + .filter(validateNitroPurchase) + .map(convertNitroPurchaseToPurchase), + ); +}; + +export const finishTransaction: MutationField<'finishTransaction'> = async ( + args, +) => { + const token = args.purchase.purchaseToken ?? undefined; + if (!token) { + throw new Error('purchaseToken required to finish Amazon Vega transaction'); + } + + const params: NitroFinishTransactionParams = { + android: { + purchaseToken: token, + isConsumable: args.isConsumable ?? false, + }, + }; + await getModule().finishTransaction(params); +}; + +export const restorePurchases: MutationField<'restorePurchases'> = async () => { + await getVegaModule().restorePurchases?.(); +}; + +export const getActiveSubscriptions: QueryField< + 'getActiveSubscriptions' +> = async (args) => { + return getModule().getActiveSubscriptions(args ?? undefined); +}; + +export const hasActiveSubscriptions: QueryField< + 'hasActiveSubscriptions' +> = async (args) => { + return getModule().hasActiveSubscriptions(args ?? undefined); +}; + +export const purchaseUpdatedListener = ( + listener: (purchase: Purchase) => void, + options?: PurchaseUpdatedListenerOptions | null, +): EventSubscription => { + const token = getModule().addPurchaseUpdatedListener((purchase) => { + if (validateNitroPurchase(purchase)) { + listener(convertNitroPurchaseToPurchase(purchase)); + } + }, options ?? undefined); + + return { + remove: () => { + if (typeof token === 'number') { + getModule().removePurchaseUpdatedListener(token); + } + }, + }; +}; + +export const purchaseErrorListener = ( + listener: (error: PurchaseError) => void, +): EventSubscription => { + const nativeListener = (error: {code?: string; message?: string}) => { + listener({ + code: (error.code ?? 'service-error') as PurchaseError['code'], + message: error.message ?? 'Amazon Vega purchase failed', + }); + }; + + getModule().addPurchaseErrorListener(nativeListener); + return { + remove: () => getModule().removePurchaseErrorListener(nativeListener), + }; +}; + +export const getStorefront: QueryField<'getStorefront'> = async () => { + return getModule().getStorefront(); +}; + +export const verifyPurchaseWithProvider: MutationField< + 'verifyPurchaseWithProvider' +> = async (params) => { + return getModule().verifyPurchaseWithProvider(params) as ReturnType< + MutationField<'verifyPurchaseWithProvider'> + >; +}; + +export const validateReceipt: MutationField<'validateReceipt'> = async () => { + return unsupported('validateReceipt'); +}; + +export const verifyPurchase: MutationField<'verifyPurchase'> = validateReceipt; +export const validateReceiptIOS: QueryField<'validateReceiptIOS'> = async () => + unsupported('validateReceiptIOS'); +export const syncIOS: MutationField<'syncIOS'> = async () => + unsupported('syncIOS'); +export const getAppTransactionIOS: QueryField< + 'getAppTransactionIOS' +> = async () => null; +export const getPromotedProductIOS: QueryField< + 'getPromotedProductIOS' +> = async () => null; +export const requestPromotedProductIOS = getPromotedProductIOS; +export const requestPurchaseOnPromotedProductIOS = async (): Promise => + unsupported('requestPurchaseOnPromotedProductIOS'); +export const showManageSubscriptionsIOS: MutationField< + 'showManageSubscriptionsIOS' +> = async () => []; +export const presentCodeRedemptionSheetIOS: MutationField< + 'presentCodeRedemptionSheetIOS' +> = async () => false; +export const presentExternalPurchaseLinkIOS: MutationField< + 'presentExternalPurchaseLinkIOS' +> = async () => unsupported('presentExternalPurchaseLinkIOS'); +export const deepLinkToSubscriptions: MutationField< + 'deepLinkToSubscriptions' +> = async () => unsupported('deepLinkToSubscriptions'); +export const promotedProductListenerIOS = (): EventSubscription => ({ + remove: () => {}, +}); + +export const acknowledgePurchaseAndroid: MutationField< + 'acknowledgePurchaseAndroid' +> = async (purchaseToken) => { + return getVegaModule().acknowledgePurchaseAndroid(purchaseToken); +}; +export const consumePurchaseAndroid: MutationField< + 'consumePurchaseAndroid' +> = async (purchaseToken) => { + return getVegaModule().consumePurchaseAndroid(purchaseToken); +}; +export const acknowledgePurchase = acknowledgePurchaseAndroid; +export const consumePurchase = consumePurchaseAndroid; + +export const checkAlternativeBillingAvailabilityAndroid: MutationField< + 'checkAlternativeBillingAvailabilityAndroid' +> = async () => unsupported('checkAlternativeBillingAvailabilityAndroid'); +export const showAlternativeBillingDialogAndroid: MutationField< + 'showAlternativeBillingDialogAndroid' +> = async () => unsupported('showAlternativeBillingDialogAndroid'); +export const createAlternativeBillingTokenAndroid: MutationField< + 'createAlternativeBillingTokenAndroid' +> = async () => unsupported('createAlternativeBillingTokenAndroid'); +export const isBillingProgramAvailableAndroid: MutationField< + 'isBillingProgramAvailableAndroid' +> = async () => unsupported('isBillingProgramAvailableAndroid'); +export const launchExternalLinkAndroid: MutationField< + 'launchExternalLinkAndroid' +> = async () => unsupported('launchExternalLinkAndroid'); +export const createBillingProgramReportingDetailsAndroid: MutationField< + 'createBillingProgramReportingDetailsAndroid' +> = async () => unsupported('createBillingProgramReportingDetailsAndroid'); +export const userChoiceBillingListenerAndroid = (): EventSubscription => ({ + remove: () => {}, +}); +export const developerProvidedBillingListenerAndroid = + (): EventSubscription => ({ + remove: () => {}, + }); +export const subscriptionBillingIssueListener = (): EventSubscription => ({ + remove: () => {}, +}); diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 0f65e6fd..ffd14746 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -64,6 +64,7 @@ import { import {RnIapConsole} from './utils/debug'; import {getSuccessFromPurchaseVariant} from './utils/purchase'; import {parseAppTransactionPayload} from './utils'; +import {getVegaIapModule, isVegaOS} from './vega'; // ------------------------------ // Billing Programs API (Android 8.2.0+) @@ -72,9 +73,7 @@ import {parseAppTransactionPayload} from './utils'; // Note: BillingProgramAndroid, ExternalLinkLaunchModeAndroid, and ExternalLinkTypeAndroid // are exported from './types' (auto-generated from openiap-gql). // Import them here for use in this file's interfaces and functions. -import type { - BillingProgramAndroid, -} from './types'; +import type {BillingProgramAndroid} from './types'; // Export all types export type { @@ -85,6 +84,7 @@ export type { } from './specs/RnIap.nitro'; export * from './types'; export * from './utils/error'; +export * from './vega'; export type ProductTypeInput = 'inapp' | 'in-app' | 'subs'; @@ -167,6 +167,10 @@ let iapRef: RnIap | null = null; */ export const isNitroReady = (): boolean => { if (iapRef) return true; + if (isVegaOS()) { + iapRef = getVegaIapModule(); + return Boolean(iapRef); + } try { iapRef = NitroModules.createHybridObject('RnIap'); return true; @@ -201,10 +205,25 @@ export const isStandardIOS = (): boolean => { return Platform.OS === 'ios' && !isTVOS() && !isMacOS(); }; +const isAndroidStoreRuntime = (): boolean => { + return Platform.OS === 'android' || isVegaOS(); +}; + const IAP = { get instance(): RnIap { if (iapRef) return iapRef; + if (isVegaOS()) { + const vegaModule = getVegaIapModule(); + if (!vegaModule) { + throw new Error( + 'Amazon Vega IAP module is unavailable. Install @amazon-devices/keplerscript-appstore-iap-lib in the Vega app target and build with the React Native for Vega kepler platform.', + ); + } + iapRef = vegaModule; + return iapRef; + } + // Attempt to create the HybridObject and map common Nitro/JSI readiness errors try { iapRef = NitroModules.createHybridObject('RnIap'); @@ -743,7 +762,7 @@ function tryAttachSubscriptionBillingIssueNative(): void { const msg = toErrorMessage(e); if (msg.includes('Nitro runtime not installed')) { RnIapConsole.warn( - '[subscriptionBillingIssueListener] Nitro not ready yet; will retry on next registration after initConnection()', + '[subscriptionBillingIssueListener] Nitro not ready yet; will retry after initConnection()', ); } else { throw e; @@ -852,7 +871,7 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { // item.type === 'subs' case // For Android, check if subscription items have actual offers if ( - Platform.OS === 'android' && + isAndroidStoreRuntime() && item.platform === 'android' && item.type === 'subs' ) { @@ -971,11 +990,26 @@ export const getAvailablePurchases: QueryField< } return validPurchases.map(convertNitroPurchaseToPurchase); - } else if (Platform.OS === 'android') { - // For Android, we need to call twice for inapp and subs + } else if (isAndroidStoreRuntime()) { const includeSuspended = Boolean( options?.includeSuspendedAndroid ?? false, ); + + if (isVegaOS()) { + const nitroPurchases = await IAP.instance.getAvailablePurchases({ + android: {includeSuspended}, + }); + const validPurchases = nitroPurchases.filter(validateNitroPurchase); + if (validPurchases.length !== nitroPurchases.length) { + RnIapConsole.warn( + `[getAvailablePurchases] Some Vega purchases failed validation: ${nitroPurchases.length - validPurchases.length} invalid`, + ); + } + + return validPurchases.map(convertNitroPurchaseToPurchase); + } + + // For Android Play/Horizon/Fire OS, query in-app items and subscriptions separately. const inappNitroPurchases = await IAP.instance.getAvailablePurchases({ android: {type: 'inapp', includeSuspended}, }); @@ -1073,9 +1107,9 @@ export const getStorefrontIOS: QueryField<'getStorefrontIOS'> = async () => { * @see {@link https://openiap.dev/docs/apis/get-storefront} */ export const getStorefront: QueryField<'getStorefront'> = async () => { - if (Platform.OS !== 'ios' && Platform.OS !== 'android') { + if (Platform.OS !== 'ios' && !isAndroidStoreRuntime()) { RnIapConsole.warn( - '[getStorefront] Storefront lookup is only supported on iOS and Android.', + '[getStorefront] Storefront lookup is only supported on iOS, Android, and Vega OS.', ); return ''; } @@ -1559,9 +1593,13 @@ export const initConnection: MutationField<'initConnection'> = async ( config, ) => { try { - return await IAP.instance.initConnection( + const result = await IAP.instance.initConnection( config as Record | undefined, ); + if (subscriptionBillingIssueJsListeners.size > 0) { + tryAttachSubscriptionBillingIssueNative(); + } + return result; } catch (error) { RnIapConsole.error('Failed to initialize IAP connection:', error); const parsedError = parseErrorStringToJsonObj(error); @@ -1674,7 +1712,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( 'Invalid request for iOS. The `sku` property is required.', ); } - } else if (Platform.OS === 'android') { + } else if (isAndroidStoreRuntime()) { // Support both 'google' (recommended) and 'android' (deprecated) fields const androidRequest = perPlatformRequest.google ?? perPlatformRequest.android; @@ -1721,9 +1759,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( } if (isSubs) { const subscriptionRequest = iosRequest as RequestSubscriptionIosProps; - if ( - subscriptionRequest.introductoryOfferEligibility !== undefined - ) { + if (subscriptionRequest.introductoryOfferEligibility !== undefined) { iosPayload.introductoryOfferEligibility = subscriptionRequest.introductoryOfferEligibility; } @@ -1742,7 +1778,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( // Support both 'google' (recommended) and 'android' (deprecated) fields const androidRequestSource = perPlatformRequest.google ?? perPlatformRequest.android; - if (Platform.OS === 'android' && androidRequestSource) { + if (isAndroidStoreRuntime() && androidRequestSource) { const androidRequest = isSubs ? (androidRequestSource as RequestSubscriptionAndroidProps) : (androidRequestSource as RequestPurchaseAndroidProps); @@ -1849,7 +1885,7 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ( transactionId: purchase.id, }, }; - } else if (Platform.OS === 'android') { + } else if (isAndroidStoreRuntime()) { const token = purchase.purchaseToken ?? undefined; if (!token) { @@ -1907,9 +1943,9 @@ export const acknowledgePurchaseAndroid: MutationField< 'acknowledgePurchaseAndroid' > = async (purchaseToken) => { try { - if (Platform.OS !== 'android') { + if (!isAndroidStoreRuntime()) { throw new Error( - 'acknowledgePurchaseAndroid is only available on Android', + 'acknowledgePurchaseAndroid is only available on Android and Vega OS', ); } @@ -1948,8 +1984,10 @@ export const consumePurchaseAndroid: MutationField< 'consumePurchaseAndroid' > = async (purchaseToken) => { try { - if (Platform.OS !== 'android') { - throw new Error('consumePurchaseAndroid is only available on Android'); + if (!isAndroidStoreRuntime()) { + throw new Error( + 'consumePurchaseAndroid is only available on Android and Vega OS', + ); } const result = await IAP.instance.finishTransaction({ @@ -2168,8 +2206,14 @@ export const validateReceiptIOS: QueryField<'validateReceiptIOS'> = async ( * provider: 'iapkit', * iapkit: { * apiKey: 'your-api-key', - * apple: { jws: purchase.purchaseToken }, - * google: { purchaseToken: purchase.purchaseToken }, + * // Choose exactly one store payload. + * // apple: { jws: purchase.purchaseToken }, + * // google: { purchaseToken: purchase.purchaseToken }, + * amazon: { + * userId: amazonUserId, + * receiptId: purchase.purchaseToken, + * sandbox: __DEV__, + * }, * }, * }); * ``` diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index b15fcd22..3a1dfafc 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -87,7 +87,7 @@ export type IapkitPurchaseState = // Store identifier for purchase origin // Defined locally for Nitro codegen (not in GQL schema) -export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon'; +export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon' | 'amazon'; // Purchase verification provider selection // Defined locally for Nitro codegen (not in GQL schema) @@ -148,7 +148,8 @@ export interface NitroReceiptValidationHorizonOptions { userId: VerifyPurchaseHorizonOptions['userId']; } -export type NitroPurchaseUpdatedListenerOptions = PurchaseUpdatedListenerOptions; +export type NitroPurchaseUpdatedListenerOptions = + PurchaseUpdatedListenerOptions; export interface NitroReceiptValidationParams { apple?: NitroReceiptValidationAppleOptions | null; @@ -378,8 +379,18 @@ export interface NitroVerifyPurchaseWithIapkitGoogleProps { purchaseToken: string; } +export interface NitroVerifyPurchaseWithIapkitAmazonProps { + /** Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). */ + receiptId: string; + /** Use Amazon RVS Cloud Sandbox for App Tester receipts. */ + sandbox?: boolean | null; + /** Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). */ + userId?: string | null; +} + export interface NitroVerifyPurchaseWithIapkitProps { apiKey?: string | null; + amazon?: NitroVerifyPurchaseWithIapkitAmazonProps | null; apple?: NitroVerifyPurchaseWithIapkitAppleProps | null; google?: NitroVerifyPurchaseWithIapkitGoogleProps | null; } diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 518f5ffd..2e3ddc63 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -511,7 +511,7 @@ export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product export type IapPlatform = 'ios' | 'android'; -export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon'; +export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon' | 'amazon'; /** Unified purchase states from IAPKit verification response. */ export type IapkitPurchaseState = 'entitled' | 'pending-acknowledgment' | 'pending' | 'canceled' | 'expired' | 'ready-to-consume' | 'consumed' | 'unknown' | 'inauthentic'; @@ -1585,7 +1585,8 @@ export type RequestPurchaseProps = * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ export interface RequestPurchasePropsByPlatforms { @@ -1679,7 +1680,8 @@ export interface RequestSubscriptionIosProps { * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ export interface RequestSubscriptionPropsByPlatforms { @@ -1693,6 +1695,15 @@ export interface RequestSubscriptionPropsByPlatforms { ios?: (RequestSubscriptionIosProps | null); } +export interface RequestVerifyPurchaseWithIapkitAmazonProps { + /** Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). */ + receiptId: string; + /** Use Amazon RVS Cloud Sandbox for App Tester receipts. */ + sandbox?: (boolean | null); + /** Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). */ + userId?: (string | null); +} + export interface RequestVerifyPurchaseWithIapkitAppleProps { /** The JWS token returned with the purchase response. */ jws: string; @@ -1708,8 +1719,11 @@ export interface RequestVerifyPurchaseWithIapkitGoogleProps { * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) */ export interface RequestVerifyPurchaseWithIapkitProps { + /** Amazon Appstore verification parameters. */ + amazon?: (RequestVerifyPurchaseWithIapkitAmazonProps | null); /** API key used for the Authorization header (Bearer {apiKey}). */ apiKey?: (string | null); /** Apple App Store verification parameters. */ diff --git a/libraries/react-native-iap/src/types/amazon-devices-kepler/index.d.ts b/libraries/react-native-iap/src/types/amazon-devices-kepler/index.d.ts new file mode 100644 index 00000000..1c1329ed --- /dev/null +++ b/libraries/react-native-iap/src/types/amazon-devices-kepler/index.d.ts @@ -0,0 +1,14 @@ +declare module '@amazon-devices/keplerscript-appstore-iap-lib' { + export const PurchasingService: { + getProductData(request: {skus: string[]}): Promise; + getPurchaseUpdates(request: {reset: boolean}): Promise; + getUserData(request: { + fetchUserProfileAccessConsentStatus: boolean; + }): Promise; + notifyFulfillment(request: { + fulfillmentResult: number; + receiptId: string; + }): Promise; + purchase(request: {sku: string}): Promise; + }; +} diff --git a/libraries/react-native-iap/src/utils/error.ts b/libraries/react-native-iap/src/utils/error.ts index c5abd361..0d0872c6 100644 --- a/libraries/react-native-iap/src/utils/error.ts +++ b/libraries/react-native-iap/src/utils/error.ts @@ -32,6 +32,37 @@ export function parseErrorStringToJsonObj( ): IapError { // Handle Error objects if (errorString instanceof Error) { + const errorWithFields = errorString as Error & Partial; + if ( + errorWithFields.code != null || + errorWithFields.responseCode != null || + errorWithFields.debugMessage != null || + errorWithFields.productId != null + ) { + const parsed: IapError = { + code: String(errorWithFields.code ?? ErrorCode.Unknown), + message: errorString.message, + }; + if (errorWithFields.responseCode !== undefined) { + parsed.responseCode = errorWithFields.responseCode; + } + if (errorWithFields.debugMessage !== undefined) { + parsed.debugMessage = errorWithFields.debugMessage; + } + if (errorWithFields.productId !== undefined) { + parsed.productId = errorWithFields.productId; + } + if (errorWithFields.productIds !== undefined) { + parsed.productIds = errorWithFields.productIds; + } + if (errorWithFields.productType !== undefined) { + parsed.productType = errorWithFields.productType; + } + if (errorWithFields.isEmptyProductList !== undefined) { + parsed.isEmptyProductList = errorWithFields.isEmptyProductList; + } + return parsed; + } errorString = errorString.message; } diff --git a/libraries/react-native-iap/src/utils/type-bridge.ts b/libraries/react-native-iap/src/utils/type-bridge.ts index 60739027..e1fe3019 100644 --- a/libraries/react-native-iap/src/utils/type-bridge.ts +++ b/libraries/react-native-iap/src/utils/type-bridge.ts @@ -35,6 +35,7 @@ const STORE_UNKNOWN: IapStore = 'unknown'; const STORE_APPLE: IapStore = 'apple'; const STORE_GOOGLE: IapStore = 'google'; const STORE_HORIZON: IapStore = 'horizon'; +const STORE_AMAZON: IapStore = 'amazon'; const PRODUCT_TYPE_SUBS: ProductType = 'subs'; const PRODUCT_TYPE_IN_APP: ProductType = 'in-app'; const PURCHASE_STATE_PENDING: PurchaseState = 'pending'; @@ -67,6 +68,8 @@ function normalizeStore(value?: Nullable): IapStore { return STORE_GOOGLE; case 'horizon': return STORE_HORIZON; + case 'amazon': + return STORE_AMAZON; default: return STORE_UNKNOWN; } @@ -191,7 +194,7 @@ function toNullableBoolean(value: unknown): boolean | null { function parseSubscriptionOffers(value?: Nullable | any[]) { if (!value) return undefined; - // If it's already an array (from mocks), return it as-is + // Keep pre-normalized native values intact. if (Array.isArray(value)) { return value; } diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts new file mode 100644 index 00000000..843849f1 --- /dev/null +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -0,0 +1,1503 @@ +import type { + IapkitPurchaseState, + NitroActiveSubscription, + NitroProduct, + NitroPurchase, + NitroPurchaseResult, + NitroVerifyPurchaseWithProviderProps, + NitroVerifyPurchaseWithProviderResult, + RnIap, +} from './specs/RnIap.nitro'; +import {ErrorCode} from './types'; + +type ResponseOperation = + | 'product-data' + | 'purchase' + | 'purchase-updates' + | 'user-data' + | 'notify-fulfillment'; + +const IAPKIT_VERIFY_TIMEOUT_MS = 10_000; +const MAX_IAPKIT_ERROR_DEPTH = 5; +const MAX_PRODUCT_DATA_BATCH_SIZE = 100; +const MAX_PURCHASE_UPDATE_PAGES = 100; +const NOTIFY_FULFILLMENT_MAX_ATTEMPTS = 15; +const NOTIFY_FULFILLMENT_RETRY_DELAY_MS = 1_000; +const PURCHASE_UPDATES_MAX_ATTEMPTS = 5; +const PURCHASE_UPDATES_RETRY_DELAY_MS = 1_000; +const PURCHASE_RECOVERY_CLOCK_SKEW_MS = 5_000; + +interface VegaPrice { + priceCurrencyCode?: string | null; + priceStr?: string | null; + valueInMicros?: bigint | number | string | null; +} + +interface VegaProduct { + description?: string | null; + freeTrialPeriod?: string | null; + itemType?: unknown; + price?: VegaPrice | number | string | null; + productType?: unknown; + sku?: string | null; + subscriptionBase?: string | null; + subscriptionParent?: string | null; + subscriptionPeriod?: string | null; + term?: string | null; + title?: string | null; +} + +interface VegaReceipt { + cancelDate?: Date | number | string | null; + deferredDate?: Date | number | string | null; + isCancelled?: boolean | null; + isDeferred?: boolean | null; + productType?: unknown; + purchaseDate?: Date | number | string | null; + receiptId?: string | null; + sku?: string | null; + termSku?: string | null; +} + +interface VegaUserData { + countryCode?: string | null; + marketplace?: string | null; + userId?: string | null; +} + +interface VegaResponse { + responseCode?: unknown; +} + +interface VegaProductDataResponse extends VegaResponse { + productData?: Map | Record | null; + unavailableSkus?: string[] | null; +} + +interface VegaPurchaseResponse extends VegaResponse { + receipt?: VegaReceipt | null; + userData?: VegaUserData | null; +} + +interface VegaPurchaseUpdatesResponse extends VegaResponse { + hasMore?: boolean | null; + receiptList?: VegaReceipt[] | null; + userData?: VegaUserData | null; +} + +interface VegaUserDataResponse extends VegaResponse { + userData?: VegaUserData | null; +} + +interface VegaUserDataRequest { + fetchUserProfileAccessConsentStatus: boolean; +} + +interface VegaError extends Error { + code?: ErrorCode; + debugMessage?: string; + platform?: 'android'; + productId?: string; + responseCode?: number; +} + +export interface VegaPurchasingService { + getProductData(request: {skus: string[]}): Promise; + getPurchaseUpdates(request: { + reset: boolean; + }): Promise; + getUserData(request: VegaUserDataRequest): Promise; + notifyFulfillment(request: { + fulfillmentResult: number; + receiptId: string; + }): Promise; + purchase(request: {sku: string}): Promise; +} + +const PRODUCT_TYPE_SUBSCRIPTION = 3; +const FULFILLMENT_RESULT_FULFILLED = 1; +const RESPONSE_SUCCESS = 1; +const PURCHASE_RESPONSE_SUCCESS = 0; +const PURCHASE_STATE_PURCHASED = 1; +const PURCHASE_STATE_PENDING = 2; +const IAPKIT_DEFAULT_BASE_URL = 'https://kit.openiap.dev'; +const IAPKIT_VERIFY_PATH = '/v1/purchase/verify'; +const VEGA_PARSER_ERROR_MESSAGES = [ + 'Cannot convert undefined value to object', + 'userId is not found while parsing Json', +]; + +function createVegaError( + code: ErrorCode, + message: string, + responseCode?: unknown, + productId?: string, +): Error { + const error = new Error(message) as VegaError; + error.code = code; + error.debugMessage = message; + error.platform = 'android'; + error.productId = productId; + if (typeof responseCode === 'number') { + error.responseCode = responseCode; + } + return error; +} + +function parseVegaErrorPayload(error: unknown): Record { + if (!(error instanceof Error)) return {}; + const vegaError = error as VegaError; + if ( + vegaError.code != null || + vegaError.debugMessage != null || + vegaError.productId != null || + vegaError.responseCode != null + ) { + return { + code: vegaError.code, + message: error.message, + responseCode: vegaError.responseCode, + debugMessage: vegaError.debugMessage, + productId: vegaError.productId, + platform: vegaError.platform, + }; + } + try { + const parsed = JSON.parse(error.message); + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function toPurchaseErrorResult( + error: unknown, + fallbackMessage: string, +): NitroPurchaseResult { + const parsed = parseVegaErrorPayload(error); + const responseCode = parsed.responseCode; + return { + responseCode: typeof responseCode === 'number' ? responseCode : -1, + code: + typeof parsed.code === 'string' ? parsed.code : ErrorCode.PurchaseError, + message: + typeof parsed.message === 'string' + ? parsed.message + : error instanceof Error + ? error.message + : fallbackMessage, + debugMessage: + typeof parsed.debugMessage === 'string' ? parsed.debugMessage : undefined, + purchaseToken: undefined, + }; +} + +function responseCodeName(responseCode: unknown): string { + if (typeof responseCode === 'string') { + return responseCode.toUpperCase(); + } + return ''; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function isSuccess( + operation: ResponseOperation, + responseCode: unknown, +): boolean { + if (typeof responseCode === 'number') { + return operation === 'purchase' + ? responseCode === PURCHASE_RESPONSE_SUCCESS + : responseCode === RESPONSE_SUCCESS; + } + + const name = responseCodeName(responseCode); + return name === 'SUCCESSFUL' || name === 'SUCCESS' || name === 'OK'; +} + +function mapErrorCode( + operation: ResponseOperation, + responseCode: unknown, +): ErrorCode { + const name = responseCodeName(responseCode); + if (name.includes('ALREADY_PURCHASED')) return ErrorCode.AlreadyOwned; + if (name.includes('INVALID_SKU')) return ErrorCode.SkuNotFound; + if (name.includes('NOT_SUPPORTED')) return ErrorCode.FeatureNotSupported; + if (name.includes('PENDING')) return ErrorCode.Pending; + if (name.includes('FAILED')) { + return operation === 'purchase' + ? ErrorCode.UserCancelled + : ErrorCode.ServiceError; + } + + if (typeof responseCode === 'number') { + if (operation === 'purchase') { + if (responseCode === 1) return ErrorCode.AlreadyOwned; + if (responseCode === 2) return ErrorCode.SkuNotFound; + if (responseCode === 3) return ErrorCode.FeatureNotSupported; + if (responseCode === 4) return ErrorCode.UserCancelled; + } + if (operation !== 'purchase' && responseCode === 2) { + return ErrorCode.FeatureNotSupported; + } + if (operation !== 'purchase' && responseCode === 3) { + return ErrorCode.ServiceError; + } + if (operation !== 'purchase' && responseCode === 4) { + return ErrorCode.ServiceError; + } + } + + if (operation === 'product-data') return ErrorCode.QueryProduct; + if (operation === 'user-data') return ErrorCode.InitConnection; + return ErrorCode.PurchaseError; +} + +function shouldRetryResponse( + operation: ResponseOperation, + responseCode: unknown, +): boolean { + if (isSuccess(operation, responseCode)) return false; + if (operation === 'purchase') return false; + if (typeof responseCode === 'number') return responseCode === 3; + return responseCodeName(responseCode).includes('FAILED'); +} + +function shouldRecoverPurchaseResponse(responseCode: unknown): boolean { + if (typeof responseCode === 'number') { + return responseCode === 1 || responseCode === 4; + } + const name = responseCodeName(responseCode); + return name.includes('ALREADY_PURCHASED') || name.includes('FAILED'); +} + +function ensureSuccessful( + operation: ResponseOperation, + response: VegaResponse | null | undefined, + message: string, + productId?: string, +): void { + const responseCode = response?.responseCode; + if (isSuccess(operation, responseCode)) return; + + throw createVegaError( + mapErrorCode(operation, responseCode), + `${message}. Amazon Vega responseCode=${String(responseCode ?? 'unknown')}`, + responseCode, + productId, + ); +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value ?? {}); +} + +function toTimestamp(value: unknown): number { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; + } + return 0; +} + +function priceNumberToMicros(value: number): number { + return Math.trunc(Math.abs(value) < 10_000 ? value * 1_000_000 : value); +} + +function toPriceAmountMicros(value: unknown): string { + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value).toString(); + } + if (typeof value === 'string' && value.length > 0) return value; + return '0'; +} + +function getPriceObject(product: VegaProduct): VegaPrice { + return product.price != null && + typeof product.price === 'object' && + !Array.isArray(product.price) + ? product.price + : {}; +} + +function getPriceAmountMicros(product: VegaProduct): unknown { + if (typeof product.price === 'number' && Number.isFinite(product.price)) { + return priceNumberToMicros(product.price); + } + return getPriceObject(product).valueInMicros; +} + +function microsToPrice(value: unknown): number | null { + const micros = + typeof value === 'bigint' ? Number(value) : Number(value ?? Number.NaN); + if (!Number.isFinite(micros)) return null; + return micros / 1_000_000; +} + +function getPrice(product: VegaProduct): number | null { + if (typeof product.price === 'number' && Number.isFinite(product.price)) { + return Math.abs(product.price) < 10_000 + ? product.price + : product.price / 1_000_000; + } + return microsToPrice(getPriceObject(product).valueInMicros); +} + +function getDisplayPrice(product: VegaProduct): string { + const price = product.price; + if (typeof price === 'number' && Number.isFinite(price)) { + const value = Math.abs(price) < 10_000 ? price : price / 1_000_000; + return value.toFixed(2); + } + if (typeof price === 'string') return price; + return getPriceObject(product).priceStr ?? ''; +} + +function getCurrency(product: VegaProduct): string { + return getPriceObject(product).priceCurrencyCode ?? ''; +} + +function isSubscription(productType: unknown): boolean { + if (typeof productType === 'number') { + return productType === PRODUCT_TYPE_SUBSCRIPTION; + } + + if (typeof productType === 'string') { + return productType.toUpperCase().includes('SUBSCRIPTION'); + } + + return false; +} + +function productTypeToOpenIap(productType: unknown): 'in-app' | 'subs' { + return isSubscription(productType) ? 'subs' : 'in-app'; +} + +function getProductType(product: VegaProduct): unknown { + return product.productType ?? product.itemType; +} + +function getSubscriptionPeriod(product: VegaProduct): string { + if (product.subscriptionPeriod) return product.subscriptionPeriod; + + const term = product.term?.toLowerCase() ?? ''; + if (term.includes('year')) return 'P1Y'; + if (term.includes('month')) return 'P1M'; + if (term.includes('week')) return 'P1W'; + if (term.includes('day')) return 'P1D'; + return ''; +} + +function getReceiptSku(receipt: VegaReceipt): string { + return receipt.sku ?? receipt.termSku ?? ''; +} + +function getCachedProductType( + receipt: VegaReceipt, + productTypesBySku: Map, + fallbackSku?: string, +): unknown { + const sku = getReceiptSku(receipt) || fallbackSku || ''; + return sku ? productTypesBySku.get(sku) : undefined; +} + +function productDataToArray( + productData?: Map | Record | null, +): VegaProduct[] { + if (!productData) return []; + if (productData instanceof Map) { + return Array.from(productData.entries()).map(([sku, product]) => ({ + ...product, + sku: product.sku ?? sku, + })); + } + return Object.entries(productData).map(([sku, product]) => ({ + ...product, + sku: product.sku ?? sku, + })); +} + +function chunkArray(items: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +} + +function createUserDataRequest(): VegaUserDataRequest { + return { + fetchUserProfileAccessConsentStatus: false, + }; +} + +function isVegaParserError(error: unknown): boolean { + return ( + error instanceof Error && + VEGA_PARSER_ERROR_MESSAGES.some((message) => + error.message.includes(message), + ) + ); +} + +function createPricingPhase(product: VegaProduct) { + return { + billingCycleCount: 0, + billingPeriod: getSubscriptionPeriod(product), + formattedPrice: getDisplayPrice(product), + priceAmountMicros: toPriceAmountMicros(getPriceAmountMicros(product)), + priceCurrencyCode: getCurrency(product), + recurrenceMode: 1, + }; +} + +function createSubscriptionOffer(product: VegaProduct) { + const pricingPhase = createPricingPhase(product); + const sku = product.sku ?? ''; + return { + basePlanId: sku, + offerId: null, + offerTags: [], + offerToken: '', + pricingPhases: { + pricingPhaseList: [pricingPhase], + }, + }; +} + +function createStandardizedSubscriptionOffer(product: VegaProduct) { + const pricingPhase = createPricingPhase(product); + const sku = product.sku ?? ''; + return { + basePlanIdAndroid: sku, + currency: getCurrency(product), + displayPrice: getDisplayPrice(product), + id: sku, + offerTagsAndroid: [], + offerTokenAndroid: '', + paymentMode: 'pay-as-you-go', + period: null, + price: getPrice(product) ?? 0, + pricingPhasesAndroid: { + pricingPhaseList: [pricingPhase], + }, + type: 'introductory', + }; +} + +function mapProduct(product: VegaProduct): NitroProduct { + const sku = product.sku ?? ''; + const type = productTypeToOpenIap(getProductType(product)); + const subscriptionOfferDetails = + type === 'subs' ? [createSubscriptionOffer(product)] : null; + const subscriptionOffers = + type === 'subs' ? [createStandardizedSubscriptionOffer(product)] : null; + + return { + id: sku, + title: product.title ?? sku, + description: product.description ?? '', + type, + displayName: product.title ?? sku, + displayPrice: getDisplayPrice(product), + currency: getCurrency(product), + price: getPrice(product), + platform: 'android', + introductoryPricePaymentModeIOS: 'empty', + nameAndroid: product.title ?? sku, + subscriptionPeriodAndroid: getSubscriptionPeriod(product) || null, + freeTrialPeriodAndroid: product.freeTrialPeriod ?? null, + subscriptionOfferDetailsAndroid: subscriptionOfferDetails + ? stringifyJson(subscriptionOfferDetails) + : null, + subscriptionOffers: subscriptionOffers + ? stringifyJson(subscriptionOffers) + : null, + productStatusAndroid: 'ok', + }; +} + +function mapReceipt( + receipt: VegaReceipt, + fallbackProductType?: unknown, + productIdOverride?: string, +): NitroPurchase { + const receiptId = receipt.receiptId ?? ''; + const productId = productIdOverride ?? getReceiptSku(receipt); + const type = productTypeToOpenIap(receipt.productType ?? fallbackProductType); + const isPending = Boolean(receipt.isDeferred); + const isCanceled = Boolean(receipt.isCancelled || receipt.cancelDate); + const isActive = !isCanceled && !isPending; + + return { + id: receiptId, + productId, + transactionDate: toTimestamp(receipt.purchaseDate), + purchaseToken: receiptId, + platform: 'android', + store: 'amazon', + quantity: 1, + purchaseState: isPending ? 'pending' : isActive ? 'purchased' : 'unknown', + isAutoRenewing: type === 'subs' && isActive, + purchaseTokenAndroid: receiptId, + dataAndroid: stringifyJson(receipt), + signatureAndroid: null, + autoRenewingAndroid: type === 'subs' && isActive, + purchaseStateAndroid: isPending + ? PURCHASE_STATE_PENDING + : isActive + ? PURCHASE_STATE_PURCHASED + : 0, + isAcknowledgedAndroid: false, + isSuspendedAndroid: Boolean(receipt.isDeferred), + }; +} + +function getSkuFromRequest(request: Parameters[0]) { + const androidRequest = request.google ?? request.android; + const skus = androidRequest?.skus ?? []; + if (skus.length !== 1) { + throw createVegaError( + ErrorCode.DeveloperError, + 'Amazon Vega purchase expects exactly one SKU per request.', + ); + } + return skus[0]!; +} + +function hasSubscriptionRequestContext(subscriptionOffers: unknown): boolean { + if (Array.isArray(subscriptionOffers)) return true; + if (typeof subscriptionOffers === 'string') { + return subscriptionOffers.trim().length > 0; + } + return subscriptionOffers != null; +} + +function throwUnsupportedFeature(feature: string): never { + throw createVegaError( + ErrorCode.FeatureNotSupported, + `${feature} is not supported on Amazon Vega.`, + ); +} + +type VegaRnIapModule = Partial & { + acknowledgePurchaseAndroid(purchaseToken: string): Promise; + consumePurchaseAndroid(purchaseToken: string): Promise; + restorePurchases(): Promise; +}; + +interface RecoveredNitroPurchases { + requestedPurchases: NitroPurchase[]; +} + +interface RecoverPurchasesOptions { + minPurchaseDateMs?: number; +} + +export function createVegaIapModule(service: VegaPurchasingService): RnIap { + const productTypesBySku = new Map(); + const subscriptionBasesBySku = new Map(); + const subscriptionParentsBySku = new Map(); + const purchaseUpdateListeners = new Map< + number, + (purchase: NitroPurchase) => void + >(); + const purchaseErrorListeners = new Set< + (error: NitroPurchaseResult) => void + >(); + let cachedUserData: VegaUserData | null = null; + let nextPurchaseUpdateListenerToken = 1; + + const emitPurchaseUpdated = (purchase: NitroPurchase): void => { + for (const listener of purchaseUpdateListeners.values()) { + listener(purchase); + } + }; + + const emitPurchaseError = (error: NitroPurchaseResult): void => { + for (const listener of purchaseErrorListeners) { + listener(error); + } + }; + + const cacheProductMetadata = (product: VegaProduct): void => { + if (!product.sku) return; + + const productType = getProductType(product); + productTypesBySku.set(product.sku, productType); + + if (product.subscriptionBase) { + subscriptionBasesBySku.set(product.sku, product.subscriptionBase); + productTypesBySku.set(product.subscriptionBase, productType); + } + if (product.subscriptionParent) { + subscriptionParentsBySku.set(product.sku, product.subscriptionParent); + productTypesBySku.set(product.subscriptionParent, productType); + } + }; + + const receiptMatchesRequestedSku = ( + receipt: VegaReceipt, + sku: string, + ): boolean => { + const receiptSku = getReceiptSku(receipt); + if (!receiptSku) return false; + if (receiptSku === sku || receipt.termSku === sku) return true; + if (receiptSku === subscriptionBasesBySku.get(sku)) return true; + if (receiptSku === subscriptionParentsBySku.get(sku)) return true; + return receiptSku === `${sku}.base`; + }; + + const resolveReceiptProductId = ( + receipt: VegaReceipt, + productIdOverride?: string, + ): string => { + if (productIdOverride) return productIdOverride; + + const receiptSku = getReceiptSku(receipt); + if (!receiptSku) return ''; + + for (const [productSku, subscriptionBase] of subscriptionBasesBySku) { + if (receiptSku === subscriptionBase) return productSku; + } + + for (const [productSku, subscriptionParent] of subscriptionParentsBySku) { + if (receiptSku === subscriptionParent) return productSku; + } + + if (receiptSku.endsWith('.base')) { + const parentSku = receiptSku.slice(0, -'.base'.length); + if (productTypesBySku.has(parentSku)) return parentSku; + } + + return receiptSku; + }; + + const getUserData = async (): Promise => { + let response: VegaUserDataResponse; + try { + response = await service.getUserData(createUserDataRequest()); + } catch (error) { + if (isVegaParserError(error)) { + return null; + } + throw error; + } + ensureSuccessful('user-data', response, 'Failed to fetch Amazon user data'); + cachedUserData = response.userData ?? null; + return cachedUserData; + }; + + const getStorefront = async (): Promise => { + await getUserData(); + return cachedUserData?.marketplace ?? cachedUserData?.countryCode ?? ''; + }; + + const getPurchaseUpdateReceipts = async (): Promise => { + const receipts: VegaReceipt[] = []; + let reset = true; + let hasMore = false; + let pageCount = 0; + + do { + if (pageCount >= MAX_PURCHASE_UPDATE_PAGES) { + throw createVegaError( + ErrorCode.ServiceError, + 'Amazon Vega purchase updates exceeded the pagination limit.', + ); + } + pageCount++; + + let response: VegaPurchaseUpdatesResponse | null = null; + for ( + let attempt = 1; + attempt <= PURCHASE_UPDATES_MAX_ATTEMPTS; + attempt += 1 + ) { + try { + response = await service.getPurchaseUpdates({reset}); + } catch (error) { + if (isVegaParserError(error)) { + return receipts; + } + throw error; + } + + if ( + !response || + !shouldRetryResponse('purchase-updates', response.responseCode) || + attempt === PURCHASE_UPDATES_MAX_ATTEMPTS + ) { + break; + } + await delay(PURCHASE_UPDATES_RETRY_DELAY_MS); + } + if (!response) { + throw createVegaError( + ErrorCode.ServiceError, + 'Amazon Vega purchase updates returned no response.', + ); + } + ensureSuccessful( + 'purchase-updates', + response, + 'Failed to fetch Amazon purchase updates', + ); + cachedUserData = response.userData ?? cachedUserData; + receipts.push(...(response.receiptList ?? [])); + hasMore = Boolean(response.hasMore); + reset = false; + } while (hasMore); + + return receipts; + }; + + const getProductData = async ( + skus: string[], + message: string, + ): Promise => { + const products: VegaProduct[] = []; + for (const batch of chunkArray(skus, MAX_PRODUCT_DATA_BATCH_SIZE)) { + const response = await service.getProductData({skus: batch}); + ensureSuccessful('product-data', response, message); + products.push(...productDataToArray(response.productData)); + } + return products; + }; + + const hydrateProductTypesForReceipts = async ( + receipts: VegaReceipt[], + ): Promise => { + const missingSkus = new Set(); + + for (const receipt of receipts) { + const sku = getReceiptSku(receipt); + if (!sku) continue; + if (receipt.productType != null) { + productTypesBySku.set(sku, receipt.productType); + continue; + } + + const resolvedSku = resolveReceiptProductId(receipt); + const resolvedProductType = productTypesBySku.get(resolvedSku); + if (resolvedProductType != null) { + productTypesBySku.set(sku, resolvedProductType); + } else if (!productTypesBySku.has(sku)) { + missingSkus.add(sku); + } + } + + if (missingSkus.size === 0) return; + + let products: VegaProduct[]; + try { + products = await getProductData( + Array.from(missingSkus), + 'Failed to fetch Amazon Vega product data for purchase updates', + ); + } catch (error) { + if (isVegaParserError(error)) { + return; + } + throw error; + } + + for (const product of products) { + cacheProductMetadata(product); + } + }; + + const getAvailablePurchases = async ( + options?: Parameters[0], + ): Promise => { + const requestedType = options?.android?.type; + const includeSuspended = Boolean(options?.android?.includeSuspended); + const receipts = await getPurchaseUpdateReceipts(); + await hydrateProductTypesForReceipts(receipts); + return receipts + .filter((receipt) => { + if (receipt.isCancelled || receipt.cancelDate) return false; + if (!includeSuspended && receipt.isDeferred) return false; + const openIapType = productTypeToOpenIap( + receipt.productType ?? + getCachedProductType(receipt, productTypesBySku), + ); + if (requestedType === 'subs') return openIapType === 'subs'; + if (requestedType === 'inapp') return openIapType === 'in-app'; + return true; + }) + .map((receipt) => + mapReceipt( + receipt, + getCachedProductType(receipt, productTypesBySku), + resolveReceiptProductId(receipt), + ), + ); + }; + + const finishReceipt = async ( + purchaseToken: string, + ): Promise => { + if (!purchaseToken) { + throw createVegaError( + ErrorCode.DeveloperError, + 'purchaseToken is required to finish an Amazon Vega transaction.', + ); + } + + let lastResponse: VegaResponse | null = null; + for ( + let attempt = 1; + attempt <= NOTIFY_FULFILLMENT_MAX_ATTEMPTS; + attempt += 1 + ) { + const response = await service.notifyFulfillment({ + fulfillmentResult: FULFILLMENT_RESULT_FULFILLED, + receiptId: purchaseToken, + }); + if (isSuccess('notify-fulfillment', response?.responseCode)) { + return { + responseCode: 0, + code: '', + message: '', + purchaseToken, + }; + } + + lastResponse = response; + if (attempt < NOTIFY_FULFILLMENT_MAX_ATTEMPTS) { + await delay(NOTIFY_FULFILLMENT_RETRY_DELAY_MS); + } + } + + ensureSuccessful( + 'notify-fulfillment', + lastResponse, + 'Failed to notify Amazon Vega fulfillment', + ); + return { + responseCode: 0, + code: '', + message: '', + purchaseToken, + }; + }; + + const recoverFulfillablePurchases = async ( + sku: string, + fallbackProductType?: unknown, + options?: RecoverPurchasesOptions, + ): Promise => { + const receipts = await getPurchaseUpdateReceipts(); + await hydrateProductTypesForReceipts(receipts); + const requestedPurchases: NitroPurchase[] = []; + + for (const receipt of receipts) { + if (receipt.isCancelled || receipt.cancelDate || receipt.isDeferred) { + continue; + } + + const purchaseTimestamp = toTimestamp(receipt.purchaseDate); + if ( + options?.minPurchaseDateMs != null && + (purchaseTimestamp === 0 || purchaseTimestamp < options.minPurchaseDateMs) + ) { + continue; + } + + const matchesRequestedSku = receiptMatchesRequestedSku(receipt, sku); + const purchase = mapReceipt( + receipt, + receipt.productType ?? + getCachedProductType(receipt, productTypesBySku, sku) ?? + (matchesRequestedSku ? fallbackProductType : undefined), + resolveReceiptProductId(receipt, matchesRequestedSku ? sku : undefined), + ); + if (matchesRequestedSku) { + requestedPurchases.push(purchase); + } + emitPurchaseUpdated(purchase); + } + + return {requestedPurchases}; + }; + + const verifyWithIapkit = async ( + params: NitroVerifyPurchaseWithProviderProps, + ): Promise => { + type IapkitEndpointOptions = NonNullable< + NitroVerifyPurchaseWithProviderProps['iapkit'] + > & { + baseUrl?: string | null; + }; + + function iapkitVerifyUrl( + iapkit: NitroVerifyPurchaseWithProviderProps['iapkit'], + ): string { + const endpointOptions = iapkit as + | IapkitEndpointOptions + | null + | undefined; + const baseUrl = + typeof endpointOptions?.baseUrl === 'string' && + endpointOptions.baseUrl.trim().length > 0 + ? endpointOptions.baseUrl.trim() + : IAPKIT_DEFAULT_BASE_URL; + return `${baseUrl.replace(/\/+$/, '')}${IAPKIT_VERIFY_PATH}`; + } + + function normalizeIapkitState(state: unknown): IapkitPurchaseState { + const normalized = + typeof state === 'string' + ? state.toLowerCase().replace(/_/g, '-') + : 'unknown'; + const states = new Set([ + 'entitled', + 'pending-acknowledgment', + 'pending', + 'canceled', + 'expired', + 'ready-to-consume', + 'consumed', + 'unknown', + 'inauthentic', + ]); + return states.has(normalized as IapkitPurchaseState) + ? (normalized as IapkitPurchaseState) + : 'unknown'; + } + + function extractIapkitErrorMessage( + json: unknown, + depth = 0, + ): string | null { + if (depth > MAX_IAPKIT_ERROR_DEPTH) return null; + if (!json || typeof json !== 'object') return null; + const record = json as Record; + function extractStringMessage(value: string): string { + if (depth >= MAX_IAPKIT_ERROR_DEPTH) return value; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' + ? (extractIapkitErrorMessage(parsed, depth + 1) ?? value) + : value; + } catch { + return value; + } + } + + const details = record.details; + if (details && typeof details === 'object') { + const originalError = (details as Record) + .originalError; + if (typeof originalError === 'string') { + return extractStringMessage(originalError); + } + } + + const errors = record.errors; + if (Array.isArray(errors) && errors.length > 0) { + const firstError = errors[0]; + return typeof firstError === 'string' + ? extractStringMessage(firstError) + : extractIapkitErrorMessage(firstError, depth + 1); + } + + if (typeof record.message === 'string') { + return extractStringMessage(record.message); + } + if (typeof record.error === 'string') { + return extractStringMessage(record.error); + } + return null; + } + + function parseIapkitJsonResponse(text: string): unknown | null { + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + return null; + } + } + + function isIapkitResultObject( + json: unknown, + ): json is Record { + return Boolean(json) && typeof json === 'object' && !Array.isArray(json); + } + + function hasIapkitErrors(json: unknown): boolean { + if (!isIapkitResultObject(json)) return false; + const errors = json.errors; + return Array.isArray(errors) && errors.length > 0; + } + + function readIapkitResult( + json: Record, + status: number, + ): { + isValid: boolean; + state: IapkitPurchaseState; + } { + if ( + typeof json.isValid !== 'boolean' || + typeof json.state !== 'string' || + json.store !== 'amazon' + ) { + throw createVegaError( + ErrorCode.ReceiptFailed, + `IAPKit returned malformed response (HTTP ${status}).`, + ); + } + + return { + isValid: json.isValid, + state: normalizeIapkitState(json.state), + }; + } + + if (params.provider !== 'iapkit') { + throw createVegaError( + ErrorCode.FeatureNotSupported, + `Unsupported purchase verification provider: ${params.provider}.`, + ); + } + + const iapkit = params.iapkit; + const payloadCount = + Number(Boolean(iapkit?.amazon)) + + Number(Boolean(iapkit?.apple)) + + Number(Boolean(iapkit?.google)); + const amazon = iapkit?.amazon; + if (payloadCount !== 1 || !amazon) { + throw createVegaError( + ErrorCode.DeveloperError, + 'Amazon Vega IAPKit verification requires exactly one amazon payload.', + ); + } + + const receiptId = + typeof amazon.receiptId === 'string' ? amazon.receiptId.trim() : ''; + if (!receiptId) { + throw createVegaError( + ErrorCode.DeveloperError, + 'Amazon Vega IAPKit verification requires amazon.receiptId.', + ); + } + + let userId = typeof amazon.userId === 'string' ? amazon.userId.trim() : ''; + if (!userId) { + await getUserData(); + userId = cachedUserData?.userId?.trim() ?? ''; + } + if (!userId) { + throw createVegaError( + ErrorCode.DeveloperError, + 'Amazon Vega IAPKit verification could not resolve userId.', + ); + } + + const apiKey = + typeof iapkit?.apiKey === 'string' ? iapkit.apiKey.trim() : ''; + let response: Response; + try { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + IAPKIT_VERIFY_TIMEOUT_MS, + ); + response = await fetch(iapkitVerifyUrl(iapkit), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? {Authorization: `Bearer ${apiKey}`} : {}), + }, + body: JSON.stringify({ + store: 'amazon', + userId, + receiptId, + ...(amazon.sandbox == null ? {} : {sandbox: amazon.sandbox}), + }), + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + } catch (error) { + throw createVegaError( + ErrorCode.NetworkError, + error instanceof Error + ? error.message + : 'Failed to reach IAPKit verification endpoint.', + ); + } + let text: string; + try { + text = await response.text(); + } catch (error) { + throw createVegaError( + ErrorCode.NetworkError, + error instanceof Error + ? error.message + : 'Failed to read IAPKit verification response.', + ); + } + const json = parseIapkitJsonResponse(text); + + if (!response.ok) { + throw createVegaError( + ErrorCode.ReceiptFailed, + extractIapkitErrorMessage(json) ?? `HTTP ${response.status}`, + ); + } + + if (json === null) { + throw createVegaError( + ErrorCode.ReceiptFailed, + `IAPKit returned non-JSON response (HTTP ${response.status}).`, + ); + } + + if (!isIapkitResultObject(json)) { + throw createVegaError( + ErrorCode.ReceiptFailed, + `IAPKit returned malformed response (HTTP ${response.status}).`, + ); + } + + if (hasIapkitErrors(json)) { + throw createVegaError( + ErrorCode.ReceiptFailed, + extractIapkitErrorMessage(json) ?? 'IAPKit verification failed.', + ); + } + + const result = readIapkitResult(json, response.status); + return { + provider: 'iapkit', + iapkit: { + isValid: result.isValid, + state: result.state, + store: 'amazon', + }, + }; + }; + + const module: VegaRnIapModule = { + async initConnection(): Promise { + return true; + }, + async endConnection(): Promise { + productTypesBySku.clear(); + subscriptionBasesBySku.clear(); + subscriptionParentsBySku.clear(); + cachedUserData = null; + return true; + }, + async fetchProducts(skus: string[], type: string): Promise { + if (!Array.isArray(skus) || skus.length === 0) { + throw createVegaError(ErrorCode.EmptySkuList, 'No SKUs provided'); + } + + const products = await getProductData( + skus, + 'Failed to fetch Amazon Vega products', + ); + + return products + .filter((product) => { + cacheProductMetadata(product); + const openIapType = productTypeToOpenIap(getProductType(product)); + if (type === 'all') return true; + if (type === 'subs') return openIapType === 'subs'; + return openIapType === 'in-app'; + }) + .map(mapProduct); + }, + async requestPurchase( + request: Parameters[0], + ): Promise>> { + let sku: string | undefined; + try { + sku = getSkuFromRequest(request); + const androidRequest = request.google ?? request.android; + const fallbackProductType = hasSubscriptionRequestContext( + androidRequest?.subscriptionOffers, + ) + ? PRODUCT_TYPE_SUBSCRIPTION + : productTypesBySku.get(sku); + if (fallbackProductType != null) { + productTypesBySku.set(sku, fallbackProductType); + } + let response: VegaPurchaseResponse; + const purchaseStartedAtMs = + Date.now() - PURCHASE_RECOVERY_CLOCK_SKEW_MS; + try { + response = await service.purchase({sku}); + } catch (error) { + if (isVegaParserError(error)) { + try { + const recovered = await recoverFulfillablePurchases( + sku, + fallbackProductType, + {minPurchaseDateMs: purchaseStartedAtMs}, + ); + if (recovered.requestedPurchases.length > 0) { + return recovered.requestedPurchases; + } + } catch { + // Keep the original parser error as the source of truth. + } + } + throw error; + } + + if ( + !isSuccess('purchase', response.responseCode) && + shouldRecoverPurchaseResponse(response.responseCode) + ) { + try { + const recovered = await recoverFulfillablePurchases( + sku, + fallbackProductType, + ); + if (recovered.requestedPurchases.length > 0) { + return recovered.requestedPurchases; + } + } catch { + // Keep the original purchase response as the source of truth. + } + } + + ensureSuccessful( + 'purchase', + response, + 'Failed to complete Amazon Vega purchase', + sku, + ); + + if (!response.receipt) return []; + + cachedUserData = response.userData ?? cachedUserData; + const purchase = mapReceipt( + response.receipt, + fallbackProductType, + sku, + ); + emitPurchaseUpdated(purchase); + return [purchase]; + } catch (error) { + emitPurchaseError( + toPurchaseErrorResult( + error, + 'Failed to complete Amazon Vega purchase', + ), + ); + throw error; + } + }, + getAvailablePurchases, + async getActiveSubscriptions( + subscriptionIds?: string[], + ): Promise { + const requestedIds = new Set(subscriptionIds ?? []); + const purchases = await getAvailablePurchases({android: {type: 'subs'}}); + return purchases + .filter( + (purchase) => + purchase.isAutoRenewing && + (requestedIds.size === 0 || requestedIds.has(purchase.productId)), + ) + .map((purchase) => ({ + productId: purchase.productId, + isActive: true, + transactionId: purchase.id, + purchaseToken: purchase.purchaseToken ?? null, + transactionDate: purchase.transactionDate, + autoRenewingAndroid: purchase.autoRenewingAndroid ?? true, + basePlanIdAndroid: purchase.productId, + currentPlanId: purchase.productId, + purchaseTokenAndroid: purchase.purchaseTokenAndroid ?? null, + })); + }, + async hasActiveSubscriptions(subscriptionIds?: string[]): Promise { + const subscriptions = + await module.getActiveSubscriptions?.(subscriptionIds); + return Boolean(subscriptions?.length); + }, + async finishTransaction( + params: Parameters[0], + ): Promise { + const token = params.android?.purchaseToken; + return finishReceipt(token ?? ''); + }, + async acknowledgePurchaseAndroid(purchaseToken): Promise { + await finishReceipt(purchaseToken); + return true; + }, + async consumePurchaseAndroid(purchaseToken): Promise { + await finishReceipt(purchaseToken); + return true; + }, + async restorePurchases(): Promise { + const purchases = await getAvailablePurchases({ + android: {includeSuspended: false}, + }); + purchases.forEach(emitPurchaseUpdated); + }, + addPurchaseUpdatedListener(listener): number { + const token = nextPurchaseUpdateListenerToken++; + purchaseUpdateListeners.set(token, listener); + return token; + }, + addPurchaseErrorListener(listener): void { + purchaseErrorListeners.add(listener); + }, + removePurchaseUpdatedListener(token): void { + purchaseUpdateListeners.delete(token); + }, + removePurchaseErrorListener(listener): void { + purchaseErrorListeners.delete(listener); + }, + addPromotedProductListenerIOS(): void {}, + removePromotedProductListenerIOS(): void {}, + async getAppTransactionIOS(): Promise { + return throwUnsupportedFeature('getAppTransactionIOS'); + }, + async requestPromotedProductIOS(): Promise { + return throwUnsupportedFeature('requestPromotedProductIOS'); + }, + async getPromotedProductIOS(): Promise { + return throwUnsupportedFeature('getPromotedProductIOS'); + }, + async buyPromotedProductIOS(): Promise { + return throwUnsupportedFeature('buyPromotedProductIOS'); + }, + async presentCodeRedemptionSheetIOS(): Promise { + return throwUnsupportedFeature('presentCodeRedemptionSheetIOS'); + }, + async clearTransactionIOS(): Promise { + return throwUnsupportedFeature('clearTransactionIOS'); + }, + async beginRefundRequestIOS(): Promise { + return throwUnsupportedFeature('beginRefundRequestIOS'); + }, + async subscriptionStatusIOS(): Promise { + return throwUnsupportedFeature('subscriptionStatusIOS'); + }, + async currentEntitlementIOS(): Promise { + return throwUnsupportedFeature('currentEntitlementIOS'); + }, + async latestTransactionIOS(): Promise { + return throwUnsupportedFeature('latestTransactionIOS'); + }, + async getPendingTransactionsIOS(): Promise { + return throwUnsupportedFeature('getPendingTransactionsIOS'); + }, + async getAllTransactionsIOS(): Promise { + return throwUnsupportedFeature('getAllTransactionsIOS'); + }, + async syncIOS(): Promise { + return throwUnsupportedFeature('syncIOS'); + }, + async showManageSubscriptionsIOS(): Promise { + return throwUnsupportedFeature('showManageSubscriptionsIOS'); + }, + async deepLinkToSubscriptionsIOS(): Promise { + return throwUnsupportedFeature('deepLinkToSubscriptionsIOS'); + }, + async isEligibleForIntroOfferIOS(): Promise { + return throwUnsupportedFeature('isEligibleForIntroOfferIOS'); + }, + async getReceiptDataIOS(): Promise { + return throwUnsupportedFeature('getReceiptDataIOS'); + }, + async getReceiptIOS(): Promise { + return throwUnsupportedFeature('getReceiptIOS'); + }, + async requestReceiptRefreshIOS(): Promise { + return throwUnsupportedFeature('requestReceiptRefreshIOS'); + }, + async isTransactionVerifiedIOS(): Promise { + return throwUnsupportedFeature('isTransactionVerifiedIOS'); + }, + async getTransactionJwsIOS(): Promise { + return throwUnsupportedFeature('getTransactionJwsIOS'); + }, + async validateReceipt(): Promise { + return throwUnsupportedFeature('validateReceipt'); + }, + async getStorefront(): Promise { + return getStorefront(); + }, + async getStorefrontIOS(): Promise { + return getStorefront(); + }, + async verifyPurchaseWithProvider(params) { + return verifyWithIapkit(params); + }, + async deepLinkToSubscriptionsAndroid(): Promise { + return throwUnsupportedFeature('deepLinkToSubscriptionsAndroid'); + }, + async checkAlternativeBillingAvailabilityAndroid(): Promise { + return throwUnsupportedFeature( + 'checkAlternativeBillingAvailabilityAndroid', + ); + }, + async showAlternativeBillingDialogAndroid(): Promise { + return throwUnsupportedFeature('showAlternativeBillingDialogAndroid'); + }, + async createAlternativeBillingTokenAndroid(): Promise { + return throwUnsupportedFeature('createAlternativeBillingTokenAndroid'); + }, + addUserChoiceBillingListenerAndroid(): void {}, + removeUserChoiceBillingListenerAndroid(): void {}, + addDeveloperProvidedBillingListenerAndroid(): void {}, + removeDeveloperProvidedBillingListenerAndroid(): void {}, + addSubscriptionBillingIssueListener(): void {}, + removeSubscriptionBillingIssueListener(): void {}, + enableBillingProgramAndroid(): void { + throwUnsupportedFeature('enableBillingProgramAndroid'); + }, + async isBillingProgramAvailableAndroid(): Promise { + return throwUnsupportedFeature('isBillingProgramAvailableAndroid'); + }, + async createBillingProgramReportingDetailsAndroid(): Promise { + return throwUnsupportedFeature( + 'createBillingProgramReportingDetailsAndroid', + ); + }, + async launchExternalLinkAndroid(): Promise { + return throwUnsupportedFeature('launchExternalLinkAndroid'); + }, + async canPresentExternalPurchaseNoticeIOS(): Promise { + return throwUnsupportedFeature('canPresentExternalPurchaseNoticeIOS'); + }, + async presentExternalPurchaseNoticeSheetIOS(): Promise { + return throwUnsupportedFeature('presentExternalPurchaseNoticeSheetIOS'); + }, + async presentExternalPurchaseLinkIOS(): Promise { + return throwUnsupportedFeature('presentExternalPurchaseLinkIOS'); + }, + async isEligibleForExternalPurchaseCustomLinkIOS(): Promise { + return throwUnsupportedFeature( + 'isEligibleForExternalPurchaseCustomLinkIOS', + ); + }, + async getExternalPurchaseCustomLinkTokenIOS(): Promise { + return throwUnsupportedFeature('getExternalPurchaseCustomLinkTokenIOS'); + }, + async showExternalPurchaseCustomLinkNoticeIOS(): Promise { + return throwUnsupportedFeature('showExternalPurchaseCustomLinkNoticeIOS'); + }, + }; + + return module as RnIap; +} diff --git a/libraries/react-native-iap/src/vega.kepler.ts b/libraries/react-native-iap/src/vega.kepler.ts new file mode 100644 index 00000000..eed381fe --- /dev/null +++ b/libraries/react-native-iap/src/vega.kepler.ts @@ -0,0 +1,21 @@ +import {Platform} from 'react-native'; +// eslint-disable-next-line import/no-unresolved +import {PurchasingService} from '@amazon-devices/keplerscript-appstore-iap-lib'; +import type {RnIap} from './specs/RnIap.nitro'; +import {createVegaIapModule, type VegaPurchasingService} from './vega-adapter'; + +let cachedVegaModule: RnIap | null = null; + +export const isVegaOS = (): boolean => { + return String(Platform.OS).toLowerCase() === 'kepler'; +}; + +export const getVegaIapModule = (): RnIap | null => { + if (!isVegaOS()) return null; + if (!cachedVegaModule) { + cachedVegaModule = createVegaIapModule( + PurchasingService as unknown as VegaPurchasingService, + ); + } + return cachedVegaModule; +}; diff --git a/libraries/react-native-iap/src/vega.ts b/libraries/react-native-iap/src/vega.ts new file mode 100644 index 00000000..8f6b974b --- /dev/null +++ b/libraries/react-native-iap/src/vega.ts @@ -0,0 +1,10 @@ +import {Platform} from 'react-native'; +import type {RnIap} from './specs/RnIap.nitro'; + +export const isVegaOS = (): boolean => { + return String(Platform.OS).toLowerCase() === 'kepler'; +}; + +export const getVegaIapModule = (): RnIap | null => { + return null; +}; diff --git a/libraries/react-native-iap/yarn.lock b/libraries/react-native-iap/yarn.lock index c6ca6161..7fa9baa0 100644 --- a/libraries/react-native-iap/yarn.lock +++ b/libraries/react-native-iap/yarn.lock @@ -12309,9 +12309,16 @@ __metadata: react-test-renderer: ^19.1.1 typescript: ^5.9.2 peerDependencies: + "@amazon-devices/keplerscript-appstore-iap-lib": ~2.12.13 + "@amazon-devices/package-manager-lib": ~1.0.1767254401 react: "*" react-native: "*" react-native-nitro-modules: ^0.35.0 + peerDependenciesMeta: + "@amazon-devices/keplerscript-appstore-iap-lib": + optional: true + "@amazon-devices/package-manager-lib": + optional: true languageName: unknown linkType: soft diff --git a/llms-full.txt b/llms-full.txt index 561c961e..05b3cdfb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -3,15 +3,16 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-05-16T12:59:43.331Z +> Generated: 2026-06-10T16:35:32.520Z ## Table of Contents 1. Installation 2. Core APIs (Connection, Products, Purchase, Subscription) 3. Platform-Specific APIs (iOS, Android) -4. Types Reference -5. Error Codes & Handling -6. Implementation Patterns +4. Store Targets (Play, Horizon, Fire OS, Vega OS) +5. Types Reference +6. Error Codes & Handling +7. Implementation Patterns --- @@ -30,19 +31,22 @@ cd ios && pod install ### Swift (iOS/macOS) ```swift // Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.1.9") +.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1") // CocoaPods -pod 'openiap', '~> 2.1.9' +pod 'openiap', '~> 2.2.1' ``` ### Kotlin (Android) ```kotlin // Gradle (build.gradle.kts) -implementation("io.github.hyochan.openiap:openiap-google:2.1.5") +implementation("io.github.hyochan.openiap:openiap-google:2.3.0-rc.1") // For Meta Horizon OS -implementation("io.github.hyochan.openiap:openiap-google-horizon:2.1.5") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.3.0-rc.1") + +// For Fire OS (Amazon Appstore) +implementation("io.github.hyochan.openiap:openiap-google-amazon:2.3.0-rc.1") ``` ### Flutter @@ -51,13 +55,13 @@ flutter pub add flutter_inapp_purchase ``` ### Godot -Download `godot-iap-2.2.10.zip` from GitHub Releases, extract it to +Download `godot-iap-2.4.0-rc.1.zip` from GitHub Releases, extract it to `addons/godot-iap/`, then enable the plugin in Project Settings. ### Kotlin Multiplatform ```kotlin dependencies { - implementation("io.github.hyochan:kmp-iap:2.2.8") + implementation("io.github.hyochan:kmp-iap:2.4.0-rc.1") } ``` @@ -69,7 +73,7 @@ https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap dotnet add package OpenIap.Maui ``` -Current NuGet package version: 1.0.4 +Current NuGet package version: 1.2.0-rc.1 Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. @@ -83,6 +87,9 @@ Requires .NET 9+, 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 can select Play, Horizon, or Fire OS artifacts. + Vega OS resolves a `kepler` JavaScript adapter before creating the Nitro + HybridObject. - Example app: `libraries/react-native-iap/example`. ### expo-iap @@ -90,6 +97,9 @@ Requires .NET 9+, 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 or Fire OS Android flavors; + Vega OS follows the Onside-style runtime selector pattern with a JavaScript + adapter. - Example app: `libraries/expo-iap/example`. ### flutter_inapp_purchase @@ -98,6 +108,7 @@ Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. iOS and Android method channels. - Public surface: singleton `FlutterInappPurchase.instance`, typed `fetchProducts`, purchase streams, and resolver-style methods. +- Android builds can select Play, Horizon, or Fire OS flavors. ### godot-iap - Package: `godot-iap` for Godot 4.x. @@ -136,6 +147,51 @@ Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. --- +## Store Targets + +- Google Play: default Android artifact, `openiap-google`. +- Meta Horizon: Android `horizon` flavor, `openiap-google-horizon`. +- Fire OS: Android `amazon` flavor, + `openiap-google-amazon`; set `amazon.fireOS=true`, + `fireOsEnabled=true`, or + `missingDimensionStrategy("platform", "amazon")`. + Runtime adapters are wired for native Android, `react-native-iap`, + `expo-iap`, and `flutter_inapp_purchase`; Godot, KMP, and MAUI have schema + type parity but still need Android wrapper flavor switches. +- Vega OS: not an Android flavor. Target React Native for Vega / Expo only, + using Amazon's JavaScript IAP API through the runtime-selected `kepler` + adapter at the same runtime integration layer as Onside. In Expo or React + Native config plugin options, use `amazon.vegaOS=true`. It marks the Vega + runtime target and does not select an Android flavor. `amazon.fireOS` and + `amazon.vegaOS` can both be enabled when an app produces separate Fire OS + and Vega OS artifacts. + +### Fire OS + +Fire OS is an Android target for Amazon Appstore distribution. It uses the +`amazon` Gradle flavor and Amazon Appstore SDK. + +Fire OS maps OpenIAP calls to the Amazon Appstore SDK: + +| OpenIAP API | Amazon Appstore SDK mapping | +|-------------|--------------------------| +| `initConnection()` | Register `PurchasingListener`, request user data | +| `fetchProducts()` | `PurchasingService.getProductData` | +| `requestPurchase()` | `PurchasingService.purchase` | +| `getAvailablePurchases()` | `PurchasingService.getPurchaseUpdates(reset=true)` | +| `finishTransaction()` | `PurchasingService.notifyFulfillment(..., FULFILLED)` | + +### Vega OS Runtime + +Vega OS is not Fire OS and is not selected with `fireOsEnabled=true`; that +flag is only for Android Fire OS builds. Use `amazon.vegaOS=true` for the +Vega runtime target and `amazon.fireOS=true` for separate Fire OS Android +artifacts. 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. + +--- + ## Minimal Usage by Framework ### React Native / Expo diff --git a/llms.txt b/llms.txt index 45cfb2e2..cadd360f 100644 --- a/llms.txt +++ b/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-05-16T12:59:43.331Z +> Generated: 2026-06-10T16:35:32.520Z ## Installation @@ -19,12 +19,14 @@ npm install react-native-iap ### Native ```swift // Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.1.9") +.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1") ``` ```kotlin // Gradle -implementation("io.github.hyochan.openiap:openiap-google:2.1.5") +implementation("io.github.hyochan.openiap:openiap-google:2.3.0-rc.1") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.3.0-rc.1") +implementation("io.github.hyochan.openiap:openiap-google-amazon:2.3.0-rc.1") ``` ```bash @@ -34,20 +36,20 @@ flutter pub add flutter_inapp_purchase ```gdscript # Godot -# Install godot-iap 2.2.10 to addons/godot-iap and enable the plugin +# Install godot-iap 2.4.0-rc.1 to addons/godot-iap and enable the plugin ``` ```kotlin // Kotlin Multiplatform -implementation("io.github.hyochan:kmp-iap:2.2.8") +implementation("io.github.hyochan:kmp-iap:2.4.0-rc.1") ``` -```bash -# .NET MAUI -dotnet add package OpenIap.Maui +```xml + + ``` -Current NuGet package version: 1.0.4 +Current NuGet package version: 1.2.0-rc.1 ## Framework Libraries @@ -80,7 +82,7 @@ await endConnection(); ```typescript const products = await fetchProducts({ products: [ - { id: 'com.app.premium', type: 'inapp' }, + { id: 'com.app.premium', type: 'in-app' }, { id: 'com.app.monthly', type: 'subs' }, ], }); @@ -95,7 +97,7 @@ await requestPurchase({ apple: { sku: 'com.app.premium' }, google: { skus: ['com.app.premium'] }, }, - type: 'inapp', // 'inapp' | 'subs' + type: 'in-app', // 'in-app' | 'subs' }); ``` @@ -103,7 +105,7 @@ await requestPurchase({ ```typescript // CRITICAL: Must call after verification // Android: purchases auto-refund after 3 days if not acknowledged -await finishTransaction(purchase, isConsumable); +await finishTransaction({ purchase, isConsumable }); ``` ### Get Available Purchases @@ -128,7 +130,7 @@ const purchaseUpdateSubscription = purchaseUpdatedListener(async (purchase) => { // 1. Verify purchase on server // 2. Grant entitlement // 3. Finish transaction - await finishTransaction(purchase); + await finishTransaction({ purchase, isConsumable: false }); }); const purchaseErrorSubscription = purchaseErrorListener((error) => { @@ -152,7 +154,7 @@ interface Product { price: string; // Formatted price string priceAmount: number; // Price as number currency: string; // ISO 4217 currency code - type: 'inapp' | 'subs'; + type: 'in-app' | 'subs'; } ``` diff --git a/openiap-versions.json b/openiap-versions.json index e997205c..0dbc6749 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,5 +1,5 @@ { "spec": "2.0.2", - "google": "2.2.1", + "google": "2.3.0-rc.1", "apple": "2.2.1" } diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 0199cfc7..cecc3c52 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -303,6 +303,7 @@ public enum IapStore: String, Codable, CaseIterable { case apple = "apple" case google = "google" case horizon = "horizon" + case amazon = "amazon" } /// Payment mode for subscription offers. @@ -1847,7 +1848,8 @@ public struct RequestPurchaseProps: Codable { /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) public struct RequestPurchasePropsByPlatforms: Codable { /// @deprecated Use google instead @@ -1975,7 +1977,8 @@ public struct RequestSubscriptionIosProps: Codable { /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) public struct RequestSubscriptionPropsByPlatforms: Codable { /// @deprecated Use google instead @@ -2000,6 +2003,25 @@ public struct RequestSubscriptionPropsByPlatforms: Codable { } } +public struct RequestVerifyPurchaseWithIapkitAmazonProps: Codable { + /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + public var receiptId: String + /// Use Amazon RVS Cloud Sandbox for App Tester receipts. + public var sandbox: Bool? + /// Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + public var userId: String? + + public init( + receiptId: String, + sandbox: Bool? = nil, + userId: String? = nil + ) { + self.receiptId = receiptId + self.sandbox = sandbox + self.userId = userId + } +} + public struct RequestVerifyPurchaseWithIapkitAppleProps: Codable { /// The JWS token returned with the purchase response. public var jws: String @@ -2026,7 +2048,10 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) public struct RequestVerifyPurchaseWithIapkitProps: Codable { + /// Amazon Appstore verification parameters. + public var amazon: RequestVerifyPurchaseWithIapkitAmazonProps? /// API key used for the Authorization header (Bearer {apiKey}). public var apiKey: String? /// Apple App Store verification parameters. @@ -2035,10 +2060,12 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { public var google: RequestVerifyPurchaseWithIapkitGoogleProps? public init( + amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = nil, apiKey: String? = nil, apple: RequestVerifyPurchaseWithIapkitAppleProps? = nil, google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil ) { + self.amazon = amazon self.apiKey = apiKey self.apple = apple self.google = google diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index ef2b2d7e..a94dacb4 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -547,6 +547,32 @@ import StoreKit } } + /// Verify purchase with external provider using the generated OpenIAP payload shape. + /// - Parameters: + /// - payload: VerifyPurchaseWithProviderProps encoded as NSDictionary + /// - completion: Callback with full verification result dictionary or error + @objc(verifyPurchaseWithProviderObjCWithPayload:completion:) + func verifyPurchaseWithProviderPayloadObjC( + payload: NSDictionary, + completion: @escaping ([String: Any]?, Error?) -> Void + ) { + Task { + do { + guard JSONSerialization.isValidJSONObject(payload) else { + throw PurchaseError(code: .developerError, message: "Invalid verification payload") + } + + let data = try JSONSerialization.data(withJSONObject: payload, options: []) + let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: data) + let result = try await verifyPurchaseWithProvider(props) + let dictionary = OpenIapSerialization.encode(result) + completion(dictionary, nil) + } catch { + completion(nil, error) + } + } + } + // MARK: - Store Information @objc func getStorefrontWithCompletion(_ completion: @escaping (String?, Error?) -> Void) { diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 12888f10..9de4aa2f 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -715,162 +715,179 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// Verify via a managed provider (currently IAPKit; the PurchaseVerificationProvider enum exposes only Iapkit today). /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider public func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { - try await ensureConnection() - guard props.provider == .iapkit else { - throw makePurchaseError(code: .featureNotSupported, message: "Provider \(props.provider.rawValue) is not supported") - } - guard let iapkit = props.iapkit else { - throw makePurchaseError(code: .developerError, message: "Missing IAPKit verification parameters") - } - let result = try await verifyPurchaseWithIapkit(props: iapkit) - return VerifyPurchaseWithProviderResult( - iapkit: result, - provider: props.provider - ) - } + struct IapkitApplePayload: Codable { + let store: IapStore + let jws: String + } + struct IapkitAmazonPayload: Codable { + let store: IapStore + let receiptId: String + let sandbox: Bool? + let userId: String? + } + struct IapkitGooglePayload: Codable { + let store: IapStore + let purchaseToken: String + } + + func extractIapkitErrorMessage(from json: [String: Any]) -> String? { + func extractStringMessage(_ value: String) -> String { + if let data = value.data(using: .utf8), + let nested = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return extractIapkitErrorMessage(from: nested) ?? value + } + return value + } - // NOTE: This Apple module intentionally sends only Apple payloads to IAPKit. - // The buildIapkitPayload function has a .google branch for type completeness, - // but it is never invoked from this module. - private func verifyPurchaseWithIapkit(props: RequestVerifyPurchaseWithIapkitProps) async throws -> RequestVerifyPurchaseWithIapkitResult { - // URL is a constant and cannot fail, so force unwrap is safe - let url = URL(string: "https://kit.openiap.dev/v1/purchase/verify")! + if let details = json["details"] as? [String: Any], + let originalError = details["originalError"] as? String { + return extractStringMessage(originalError) + } - // On Apple, only Apple verification is supported - guard props.apple != nil else { - throw makePurchaseError(code: .developerError, message: "IAPKit verification on Apple requires an apple payload") - } - let store: IapStore = .apple - let body = try buildIapkitPayload(props: props, store: store) + if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { + return extractIapkitErrorMessage(from: firstError) + } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let apiKey = props.apiKey, apiKey.isEmpty == false { - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } - request.httpBody = body + if let message = json["message"] as? String { + return extractStringMessage(message) + } - // Log request details for debugging - OpenIapLog.debug("IAPKit request URL: \(url.absoluteString)") - let requestBody = String(data: body, encoding: .utf8) ?? "<\(body.count) non-UTF8 bytes>" - OpenIapLog.debug("IAPKit request body: \(requestBody), bytes=\(body.count)") + if let error = json["error"] as? String { + return extractStringMessage(error) + } - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw makePurchaseError(code: .networkError, message: "Invalid response") + return nil } - guard (200...299).contains(httpResponse.statusCode) else { - let responseBody = String(data: data, encoding: .utf8) ?? "" - OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP \(httpResponse.statusCode))") - // Extract concise error message from IAPKit response - var errorMessage = "HTTP \(httpResponse.statusCode)" - if let jsonData = responseBody.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { - errorMessage = extractIapkitErrorMessage(from: json) ?? errorMessage + + func buildIapkitPayload(props: RequestVerifyPurchaseWithIapkitProps) throws -> (store: IapStore, body: Data) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + let payloadCount = [props.apple != nil, props.google != nil, props.amazon != nil].filter { $0 }.count + guard payloadCount == 1 else { + throw makePurchaseError(code: .developerError, message: "IAPKit verification requires exactly one store payload") } - throw makePurchaseError(code: .receiptFailed, message: errorMessage) - } - // Log only response metadata; the body can contain receipt details. - OpenIapLog.debug("IAPKit verification response received: bytes=\(data.count)") + if let apple = props.apple { + let jws = apple.jws.trimmingCharacters(in: .whitespacesAndNewlines) + guard jws.isEmpty == false else { + throw makePurchaseError(code: .developerError, message: "JWS is required") + } + let payload = IapkitApplePayload(store: .apple, jws: jws) + return (.apple, try encoder.encode(payload)) + } - // Parse manually to handle extra fields from IAPKit - // API response format: { "store": "apple", "isValid": true, "state": "PURCHASED" } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - OpenIapLog.warn("Failed to parse IAPKit verification response") - throw makePurchaseError(code: .receiptFailed, message: "Unable to parse verification response") - } + if let google = props.google { + let purchaseToken = google.purchaseToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard purchaseToken.isEmpty == false else { + throw makePurchaseError(code: .developerError, message: "Google purchase token is required") + } + let payload = IapkitGooglePayload(store: .google, purchaseToken: purchaseToken) + return (.google, try encoder.encode(payload)) + } - // Check for error response format: { "errors": [{ "code": "...", "message": "..." }] } - if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { - let errorMessage = firstError["message"] as? String ?? "Unknown error" - let errorCode = firstError["code"] as? String ?? "unknown" - OpenIapLog.warn("IAPKit verification error: \(errorCode) - \(errorMessage)") - throw makePurchaseError(code: .receiptFailed, message: errorMessage) + if let amazon = props.amazon { + let receiptId = amazon.receiptId.trimmingCharacters(in: .whitespacesAndNewlines) + guard receiptId.isEmpty == false else { + throw makePurchaseError(code: .developerError, message: "Amazon receiptId is required") + } + let userId = amazon.userId?.trimmingCharacters(in: .whitespacesAndNewlines) + let payload = IapkitAmazonPayload( + store: .amazon, + receiptId: receiptId, + sandbox: amazon.sandbox, + userId: userId?.isEmpty == true ? nil : userId + ) + return (.amazon, try encoder.encode(payload)) + } + + throw makePurchaseError(code: .developerError, message: "IAPKit verification payload is required") } - let isValid = (json["isValid"] as? Bool) ?? false - let stateString = json["state"] as? String ?? "UNKNOWN" - // IAPKit API returns UPPER_SNAKE_CASE (e.g., "PURCHASED", "PENDING_ACKNOWLEDGMENT") - // Swift enum expects lower-kebab-case (e.g., "purchased", "pending-acknowledgment") - let normalizedState = stateString.lowercased().replacingOccurrences(of: "_", with: "-") - let parsedState = IapkitPurchaseState(rawValue: normalizedState) ?? .unknown - let storeString = json["store"] as? String - let parsedStore = storeString.flatMap { IapStore(rawValue: $0) } ?? store - OpenIapLog.info("IAPKit verification result: store=\(parsedStore.rawValue), isValid=\(isValid), state=\(parsedState.rawValue)") - return RequestVerifyPurchaseWithIapkitResult(isValid: isValid, state: parsedState, store: parsedStore) - } + func verifyPurchaseWithIapkit(props: RequestVerifyPurchaseWithIapkitProps) async throws -> RequestVerifyPurchaseWithIapkitResult { + let url = URL(string: "https://kit.openiap.dev/v1/purchase/verify")! - private struct IapkitApplePayload: Codable { - let store: IapStore - let jws: String - } + let payload = try buildIapkitPayload(props: props) + let store = payload.store + let body = payload.body - private struct IapkitGooglePayload: Codable { - let store: IapStore - let purchaseToken: String - } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let apiKey = props.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) + if let apiKey, apiKey.isEmpty == false { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + request.httpBody = body + + OpenIapLog.debug("IAPKit request URL: \(url.absoluteString)") + OpenIapLog.debug("IAPKit request body bytes=\(body.count)") - private func buildIapkitPayload(props: RequestVerifyPurchaseWithIapkitProps, store: IapStore) throws -> Data { - let encoder = JSONEncoder() - encoder.outputFormatting = [.withoutEscapingSlashes] - switch store { - case .apple: - guard let apple = props.apple else { - throw makePurchaseError(code: .developerError, message: "Apple verification parameters are required") + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch is CancellationError { + throw CancellationError() + } catch { + if let urlError = error as? URLError, urlError.code == .cancelled { + throw CancellationError() + } + OpenIapLog.warn("IAPKit verification network error: \(error.localizedDescription)") + throw makePurchaseError(code: .networkError, message: error.localizedDescription) } - guard apple.jws.isEmpty == false else { - throw makePurchaseError(code: .developerError, message: "JWS is required") + guard let httpResponse = response as? HTTPURLResponse else { + throw makePurchaseError(code: .networkError, message: "Invalid response") } - let payload = IapkitApplePayload( - store: store, - jws: apple.jws - ) - return try encoder.encode(payload) - case .google, .horizon: - guard let google = props.google else { - throw makePurchaseError(code: .developerError, message: "Google verification parameters are required") + guard (200...299).contains(httpResponse.statusCode) else { + let responseBody = String(data: data, encoding: .utf8) ?? "" + OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP \(httpResponse.statusCode))") + var errorMessage = "HTTP \(httpResponse.statusCode)" + if let jsonData = responseBody.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + errorMessage = extractIapkitErrorMessage(from: json) ?? errorMessage + } + throw makePurchaseError(code: .receiptFailed, message: errorMessage) } - guard google.purchaseToken.isEmpty == false else { - throw makePurchaseError(code: .developerError, message: "purchaseToken is required") + + OpenIapLog.debug("IAPKit verification response received: bytes=\(data.count)") + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + OpenIapLog.warn("Failed to parse IAPKit verification response") + throw makePurchaseError(code: .receiptFailed, message: "Unable to parse verification response") } - let payload = IapkitGooglePayload( - store: store, - purchaseToken: google.purchaseToken - ) - return try encoder.encode(payload) - case .unknown: - throw makePurchaseError(code: .developerError, message: "Unknown store type") - } - } - /// Extract concise error message from IAPKit error response. - /// IAPKit returns nested error structures - we extract the deepest originalError for clarity. - private func extractIapkitErrorMessage(from json: [String: Any]) -> String? { - // Try to get details.originalError first (deepest level) - if let details = json["details"] as? [String: Any], - let originalError = details["originalError"] as? String { - // originalError might be a JSON string, try to parse it - if let data = originalError.data(using: .utf8), - let nested = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return extractIapkitErrorMessage(from: nested) ?? originalError + if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { + let errorMessage = extractIapkitErrorMessage(from: firstError) ?? "Unknown error" + let errorCode = firstError["code"] as? String ?? "unknown" + OpenIapLog.warn("IAPKit verification error: \(errorCode) - \(errorMessage)") + throw makePurchaseError(code: .receiptFailed, message: errorMessage) } - return originalError - } - // Try errors array format: { "errors": [{ "message": "..." }] } - if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { - return extractIapkitErrorMessage(from: firstError) + guard let isValid = json["isValid"] as? Bool, + let stateString = json["state"] as? String, + let storeString = json["store"] as? String, + let parsedStore = IapStore(rawValue: storeString), + parsedStore == store else { + OpenIapLog.warn("IAPKit verification response missing required fields") + throw makePurchaseError(code: .receiptFailed, message: "IAPKit returned malformed response") + } + let normalizedState = stateString.lowercased().replacingOccurrences(of: "_", with: "-") + let parsedState = IapkitPurchaseState(rawValue: normalizedState) ?? .unknown + OpenIapLog.info("IAPKit verification result: store=\(parsedStore.rawValue), isValid=\(isValid), state=\(parsedState.rawValue)") + return RequestVerifyPurchaseWithIapkitResult(isValid: isValid, state: parsedState, store: parsedStore) } - - // Try message field, but avoid the verbose nested JSON string - if let message = json["message"] as? String, !message.contains("{\"error\"") { - return message + try await ensureConnection() + guard props.provider == .iapkit else { + throw makePurchaseError(code: .featureNotSupported, message: "Provider \(props.provider.rawValue) is not supported") } - - // Fallback to error code - return json["error"] as? String + guard let iapkit = props.iapkit else { + throw makePurchaseError(code: .developerError, message: "Missing IAPKit verification parameters") + } + let result = try await verifyPurchaseWithIapkit(props: iapkit) + return VerifyPurchaseWithProviderResult( + iapkit: result, + provider: props.provider + ) } // MARK: - Store Information @@ -950,6 +967,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { allSubscriptions.append( ActiveSubscription( autoRenewingAndroid: nil, + currentPlanId: transaction.productID, daysUntilExpirationIOS: daysUntilExpiration, environmentIOS: environment, expirationDateIOS: expiration?.milliseconds, diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index f710f5e5..8869c54a 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -137,6 +137,7 @@ public final class OpenIapStore: ObservableObject { let newSubscription = ActiveSubscription( autoRenewingAndroid: nil, + currentPlanId: ios.productId, daysUntilExpirationIOS: nil, environmentIOS: ios.environmentIOS, expirationDateIOS: expirationDate, diff --git a/packages/apple/Tests/OpenIapTests/SubscriptionGroupMappingTests.swift b/packages/apple/Tests/OpenIapTests/SubscriptionGroupMappingTests.swift new file mode 100644 index 00000000..73b8a405 --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/SubscriptionGroupMappingTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import OpenIAP + +final class SubscriptionGroupMappingTests: XCTestCase { + func testActiveSubscriptionsKeepIndependentProductIdsForMultipleGroups() { + let premium = activeSubscription( + productId: "dev.hyo.martie.premium.monthly", + transactionId: "transaction-premium" + ) + let pro = activeSubscription( + productId: "dev.hyo.martie.pro.monthly", + transactionId: "transaction-pro" + ) + + XCTAssertEqual(premium.productId, "dev.hyo.martie.premium.monthly") + XCTAssertEqual(premium.currentPlanId, "dev.hyo.martie.premium.monthly") + XCTAssertEqual(premium.transactionId, "transaction-premium") + XCTAssertEqual(pro.productId, "dev.hyo.martie.pro.monthly") + XCTAssertEqual(pro.currentPlanId, "dev.hyo.martie.pro.monthly") + XCTAssertEqual(pro.transactionId, "transaction-pro") + } + + private func activeSubscription( + productId: String, + transactionId: String + ) -> ActiveSubscription { + ActiveSubscription( + autoRenewingAndroid: nil, + currentPlanId: productId, + isActive: true, + productId: productId, + purchaseToken: "jws-\(transactionId)", + transactionDate: 1_700_000_000_000, + transactionId: transactionId + ) + } +} diff --git a/packages/docs/openiap-versions.json b/packages/docs/openiap-versions.json index e997205c..0dbc6749 100644 --- a/packages/docs/openiap-versions.json +++ b/packages/docs/openiap-versions.json @@ -1,5 +1,5 @@ { "spec": "2.0.2", - "google": "2.2.1", + "google": "2.3.0-rc.1", "apple": "2.2.1" } diff --git a/packages/docs/public/announcements/amazon-fireos-vega.webp b/packages/docs/public/announcements/amazon-fireos-vega.webp new file mode 100644 index 00000000..1f57d514 Binary files /dev/null and b/packages/docs/public/announcements/amazon-fireos-vega.webp differ diff --git a/packages/docs/public/examples/amazon/home.webp b/packages/docs/public/examples/amazon/home.webp new file mode 100644 index 00000000..46a9cf1a Binary files /dev/null and b/packages/docs/public/examples/amazon/home.webp differ diff --git a/packages/docs/public/examples/amazon/videos/fireos-available-purchases.mp4 b/packages/docs/public/examples/amazon/videos/fireos-available-purchases.mp4 new file mode 100644 index 00000000..2e991bbe Binary files /dev/null and b/packages/docs/public/examples/amazon/videos/fireos-available-purchases.mp4 differ diff --git a/packages/docs/public/examples/amazon/videos/fireos-inapp.mp4 b/packages/docs/public/examples/amazon/videos/fireos-inapp.mp4 new file mode 100644 index 00000000..165b87bd Binary files /dev/null and b/packages/docs/public/examples/amazon/videos/fireos-inapp.mp4 differ diff --git a/packages/docs/public/examples/amazon/videos/fireos-overview.mp4 b/packages/docs/public/examples/amazon/videos/fireos-overview.mp4 new file mode 100644 index 00000000..b8f29a77 Binary files /dev/null and b/packages/docs/public/examples/amazon/videos/fireos-overview.mp4 differ diff --git a/packages/docs/public/examples/amazon/videos/fireos-subscription.mp4 b/packages/docs/public/examples/amazon/videos/fireos-subscription.mp4 new file mode 100644 index 00000000..490ffb0e Binary files /dev/null and b/packages/docs/public/examples/amazon/videos/fireos-subscription.mp4 differ diff --git a/packages/docs/public/examples/amazon/videos/fireos-verification.mp4 b/packages/docs/public/examples/amazon/videos/fireos-verification.mp4 new file mode 100644 index 00000000..a00f269f Binary files /dev/null and b/packages/docs/public/examples/amazon/videos/fireos-verification.mp4 differ diff --git a/packages/docs/public/examples/6. [IOS] Alternative Billing.webp b/packages/docs/public/examples/apple/alternative-billing.webp similarity index 100% rename from packages/docs/public/examples/6. [IOS] Alternative Billing.webp rename to packages/docs/public/examples/apple/alternative-billing.webp diff --git a/packages/docs/public/examples/4. [IOS] Available Purchases.webp b/packages/docs/public/examples/apple/available-purchases.webp similarity index 100% rename from packages/docs/public/examples/4. [IOS] Available Purchases.webp rename to packages/docs/public/examples/apple/available-purchases.webp diff --git a/packages/docs/public/examples/1. [IOS] Example.webp b/packages/docs/public/examples/apple/home.webp similarity index 100% rename from packages/docs/public/examples/1. [IOS] Example.webp rename to packages/docs/public/examples/apple/home.webp diff --git a/packages/docs/public/examples/5. [IOS] Offer Code.webp b/packages/docs/public/examples/apple/offer-code.webp similarity index 100% rename from packages/docs/public/examples/5. [IOS] Offer Code.webp rename to packages/docs/public/examples/apple/offer-code.webp diff --git a/packages/docs/public/examples/2. [IOS] Purchase Flow.webp b/packages/docs/public/examples/apple/purchase-flow.webp similarity index 100% rename from packages/docs/public/examples/2. [IOS] Purchase Flow.webp rename to packages/docs/public/examples/apple/purchase-flow.webp diff --git a/packages/docs/public/examples/3. [IOS] Subscription Flow Upgrade.webp b/packages/docs/public/examples/apple/subscription-flow-upgrade.webp similarity index 100% rename from packages/docs/public/examples/3. [IOS] Subscription Flow Upgrade.webp rename to packages/docs/public/examples/apple/subscription-flow-upgrade.webp diff --git a/packages/docs/public/examples/apple/videos/apple-available-purchases.mp4 b/packages/docs/public/examples/apple/videos/apple-available-purchases.mp4 new file mode 100644 index 00000000..8df7deea Binary files /dev/null and b/packages/docs/public/examples/apple/videos/apple-available-purchases.mp4 differ diff --git a/packages/docs/public/examples/apple/videos/apple-inapp.mp4 b/packages/docs/public/examples/apple/videos/apple-inapp.mp4 new file mode 100644 index 00000000..bb483e2a Binary files /dev/null and b/packages/docs/public/examples/apple/videos/apple-inapp.mp4 differ diff --git a/packages/docs/public/examples/apple/videos/apple-subscription.mp4 b/packages/docs/public/examples/apple/videos/apple-subscription.mp4 new file mode 100644 index 00000000..4d995ac6 Binary files /dev/null and b/packages/docs/public/examples/apple/videos/apple-subscription.mp4 differ diff --git a/packages/docs/public/examples/apple/videos/apple-verification.mp4 b/packages/docs/public/examples/apple/videos/apple-verification.mp4 new file mode 100644 index 00000000..51945339 Binary files /dev/null and b/packages/docs/public/examples/apple/videos/apple-verification.mp4 differ diff --git a/packages/docs/public/examples/5. [Android] Available Purchases.webp b/packages/docs/public/examples/google/available-purchases.webp similarity index 100% rename from packages/docs/public/examples/5. [Android] Available Purchases.webp rename to packages/docs/public/examples/google/available-purchases.webp diff --git a/packages/docs/public/examples/1. [Android] Example.webp b/packages/docs/public/examples/google/home.webp similarity index 100% rename from packages/docs/public/examples/1. [Android] Example.webp rename to packages/docs/public/examples/google/home.webp diff --git a/packages/docs/public/examples/2. [Android] Purchase Flow.webp b/packages/docs/public/examples/google/purchase-flow.webp similarity index 100% rename from packages/docs/public/examples/2. [Android] Purchase Flow.webp rename to packages/docs/public/examples/google/purchase-flow.webp diff --git a/packages/docs/public/examples/6. [Android] Redeem Offer Code.webp b/packages/docs/public/examples/google/redeem-offer-code.webp similarity index 100% rename from packages/docs/public/examples/6. [Android] Redeem Offer Code.webp rename to packages/docs/public/examples/google/redeem-offer-code.webp diff --git a/packages/docs/public/examples/4. [Android] Subscription Flow Upgrade.webp b/packages/docs/public/examples/google/subscription-flow-upgrade.webp similarity index 100% rename from packages/docs/public/examples/4. [Android] Subscription Flow Upgrade.webp rename to packages/docs/public/examples/google/subscription-flow-upgrade.webp diff --git a/packages/docs/public/examples/3. [Android] Subscription Flow.webp b/packages/docs/public/examples/google/subscription-flow.webp similarity index 100% rename from packages/docs/public/examples/3. [Android] Subscription Flow.webp rename to packages/docs/public/examples/google/subscription-flow.webp diff --git a/packages/docs/public/examples/google/videos/google-available-purchases.mp4 b/packages/docs/public/examples/google/videos/google-available-purchases.mp4 new file mode 100644 index 00000000..1c4f5a68 Binary files /dev/null and b/packages/docs/public/examples/google/videos/google-available-purchases.mp4 differ diff --git a/packages/docs/public/examples/google/videos/google-inapp.mp4 b/packages/docs/public/examples/google/videos/google-inapp.mp4 new file mode 100644 index 00000000..f1c3f722 Binary files /dev/null and b/packages/docs/public/examples/google/videos/google-inapp.mp4 differ diff --git a/packages/docs/public/examples/google/videos/google-subscription.mp4 b/packages/docs/public/examples/google/videos/google-subscription.mp4 new file mode 100644 index 00000000..252b0b30 Binary files /dev/null and b/packages/docs/public/examples/google/videos/google-subscription.mp4 differ diff --git a/packages/docs/public/examples/google/videos/google-verification.mp4 b/packages/docs/public/examples/google/videos/google-verification.mp4 new file mode 100644 index 00000000..860e99eb Binary files /dev/null and b/packages/docs/public/examples/google/videos/google-verification.mp4 differ diff --git a/packages/docs/public/examples/horizon/.gitkeep b/packages/docs/public/examples/horizon/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/docs/public/examples/horizon/.gitkeep @@ -0,0 +1 @@ + diff --git a/packages/docs/public/examples/horizon/home.webp b/packages/docs/public/examples/horizon/home.webp new file mode 100644 index 00000000..42f05588 Binary files /dev/null and b/packages/docs/public/examples/horizon/home.webp differ diff --git a/packages/docs/public/examples/horizon/videos/horizon-available-purchases.mp4 b/packages/docs/public/examples/horizon/videos/horizon-available-purchases.mp4 new file mode 100644 index 00000000..1b5b447a Binary files /dev/null and b/packages/docs/public/examples/horizon/videos/horizon-available-purchases.mp4 differ diff --git a/packages/docs/public/examples/horizon/videos/horizon-inapp.mp4 b/packages/docs/public/examples/horizon/videos/horizon-inapp.mp4 new file mode 100644 index 00000000..7bf15c16 Binary files /dev/null and b/packages/docs/public/examples/horizon/videos/horizon-inapp.mp4 differ diff --git a/packages/docs/public/examples/horizon/videos/horizon-overview.mp4 b/packages/docs/public/examples/horizon/videos/horizon-overview.mp4 new file mode 100644 index 00000000..7d9bd25f Binary files /dev/null and b/packages/docs/public/examples/horizon/videos/horizon-overview.mp4 differ diff --git a/packages/docs/public/examples/horizon/videos/horizon-subscription.mp4 b/packages/docs/public/examples/horizon/videos/horizon-subscription.mp4 new file mode 100644 index 00000000..d21ea6c6 Binary files /dev/null and b/packages/docs/public/examples/horizon/videos/horizon-subscription.mp4 differ diff --git a/packages/docs/public/examples/horizon/videos/horizon-verification.mp4 b/packages/docs/public/examples/horizon/videos/horizon-verification.mp4 new file mode 100644 index 00000000..9e489e7c Binary files /dev/null and b/packages/docs/public/examples/horizon/videos/horizon-verification.mp4 differ diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 561c961e..05b3cdfb 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,15 +3,16 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-05-16T12:59:43.331Z +> Generated: 2026-06-10T16:35:32.520Z ## Table of Contents 1. Installation 2. Core APIs (Connection, Products, Purchase, Subscription) 3. Platform-Specific APIs (iOS, Android) -4. Types Reference -5. Error Codes & Handling -6. Implementation Patterns +4. Store Targets (Play, Horizon, Fire OS, Vega OS) +5. Types Reference +6. Error Codes & Handling +7. Implementation Patterns --- @@ -30,19 +31,22 @@ cd ios && pod install ### Swift (iOS/macOS) ```swift // Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.1.9") +.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1") // CocoaPods -pod 'openiap', '~> 2.1.9' +pod 'openiap', '~> 2.2.1' ``` ### Kotlin (Android) ```kotlin // Gradle (build.gradle.kts) -implementation("io.github.hyochan.openiap:openiap-google:2.1.5") +implementation("io.github.hyochan.openiap:openiap-google:2.3.0-rc.1") // For Meta Horizon OS -implementation("io.github.hyochan.openiap:openiap-google-horizon:2.1.5") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.3.0-rc.1") + +// For Fire OS (Amazon Appstore) +implementation("io.github.hyochan.openiap:openiap-google-amazon:2.3.0-rc.1") ``` ### Flutter @@ -51,13 +55,13 @@ flutter pub add flutter_inapp_purchase ``` ### Godot -Download `godot-iap-2.2.10.zip` from GitHub Releases, extract it to +Download `godot-iap-2.4.0-rc.1.zip` from GitHub Releases, extract it to `addons/godot-iap/`, then enable the plugin in Project Settings. ### Kotlin Multiplatform ```kotlin dependencies { - implementation("io.github.hyochan:kmp-iap:2.2.8") + implementation("io.github.hyochan:kmp-iap:2.4.0-rc.1") } ``` @@ -69,7 +73,7 @@ https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap dotnet add package OpenIap.Maui ``` -Current NuGet package version: 1.0.4 +Current NuGet package version: 1.2.0-rc.1 Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. @@ -83,6 +87,9 @@ Requires .NET 9+, 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 can select Play, Horizon, or Fire OS artifacts. + Vega OS resolves a `kepler` JavaScript adapter before creating the Nitro + HybridObject. - Example app: `libraries/react-native-iap/example`. ### expo-iap @@ -90,6 +97,9 @@ Requires .NET 9+, 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 or Fire OS Android flavors; + Vega OS follows the Onside-style runtime selector pattern with a JavaScript + adapter. - Example app: `libraries/expo-iap/example`. ### flutter_inapp_purchase @@ -98,6 +108,7 @@ Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. iOS and Android method channels. - Public surface: singleton `FlutterInappPurchase.instance`, typed `fetchProducts`, purchase streams, and resolver-style methods. +- Android builds can select Play, Horizon, or Fire OS flavors. ### godot-iap - Package: `godot-iap` for Godot 4.x. @@ -136,6 +147,51 @@ Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. --- +## Store Targets + +- Google Play: default Android artifact, `openiap-google`. +- Meta Horizon: Android `horizon` flavor, `openiap-google-horizon`. +- Fire OS: Android `amazon` flavor, + `openiap-google-amazon`; set `amazon.fireOS=true`, + `fireOsEnabled=true`, or + `missingDimensionStrategy("platform", "amazon")`. + Runtime adapters are wired for native Android, `react-native-iap`, + `expo-iap`, and `flutter_inapp_purchase`; Godot, KMP, and MAUI have schema + type parity but still need Android wrapper flavor switches. +- Vega OS: not an Android flavor. Target React Native for Vega / Expo only, + using Amazon's JavaScript IAP API through the runtime-selected `kepler` + adapter at the same runtime integration layer as Onside. In Expo or React + Native config plugin options, use `amazon.vegaOS=true`. It marks the Vega + runtime target and does not select an Android flavor. `amazon.fireOS` and + `amazon.vegaOS` can both be enabled when an app produces separate Fire OS + and Vega OS artifacts. + +### Fire OS + +Fire OS is an Android target for Amazon Appstore distribution. It uses the +`amazon` Gradle flavor and Amazon Appstore SDK. + +Fire OS maps OpenIAP calls to the Amazon Appstore SDK: + +| OpenIAP API | Amazon Appstore SDK mapping | +|-------------|--------------------------| +| `initConnection()` | Register `PurchasingListener`, request user data | +| `fetchProducts()` | `PurchasingService.getProductData` | +| `requestPurchase()` | `PurchasingService.purchase` | +| `getAvailablePurchases()` | `PurchasingService.getPurchaseUpdates(reset=true)` | +| `finishTransaction()` | `PurchasingService.notifyFulfillment(..., FULFILLED)` | + +### Vega OS Runtime + +Vega OS is not Fire OS and is not selected with `fireOsEnabled=true`; that +flag is only for Android Fire OS builds. Use `amazon.vegaOS=true` for the +Vega runtime target and `amazon.fireOS=true` for separate Fire OS Android +artifacts. 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. + +--- + ## Minimal Usage by Framework ### React Native / Expo diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index 45cfb2e2..cadd360f 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-05-16T12:59:43.331Z +> Generated: 2026-06-10T16:35:32.520Z ## Installation @@ -19,12 +19,14 @@ npm install react-native-iap ### Native ```swift // Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.1.9") +.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1") ``` ```kotlin // Gradle -implementation("io.github.hyochan.openiap:openiap-google:2.1.5") +implementation("io.github.hyochan.openiap:openiap-google:2.3.0-rc.1") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.3.0-rc.1") +implementation("io.github.hyochan.openiap:openiap-google-amazon:2.3.0-rc.1") ``` ```bash @@ -34,20 +36,20 @@ flutter pub add flutter_inapp_purchase ```gdscript # Godot -# Install godot-iap 2.2.10 to addons/godot-iap and enable the plugin +# Install godot-iap 2.4.0-rc.1 to addons/godot-iap and enable the plugin ``` ```kotlin // Kotlin Multiplatform -implementation("io.github.hyochan:kmp-iap:2.2.8") +implementation("io.github.hyochan:kmp-iap:2.4.0-rc.1") ``` -```bash -# .NET MAUI -dotnet add package OpenIap.Maui +```xml + + ``` -Current NuGet package version: 1.0.4 +Current NuGet package version: 1.2.0-rc.1 ## Framework Libraries @@ -80,7 +82,7 @@ await endConnection(); ```typescript const products = await fetchProducts({ products: [ - { id: 'com.app.premium', type: 'inapp' }, + { id: 'com.app.premium', type: 'in-app' }, { id: 'com.app.monthly', type: 'subs' }, ], }); @@ -95,7 +97,7 @@ await requestPurchase({ apple: { sku: 'com.app.premium' }, google: { skus: ['com.app.premium'] }, }, - type: 'inapp', // 'inapp' | 'subs' + type: 'in-app', // 'in-app' | 'subs' }); ``` @@ -103,7 +105,7 @@ await requestPurchase({ ```typescript // CRITICAL: Must call after verification // Android: purchases auto-refund after 3 days if not acknowledged -await finishTransaction(purchase, isConsumable); +await finishTransaction({ purchase, isConsumable }); ``` ### Get Available Purchases @@ -128,7 +130,7 @@ const purchaseUpdateSubscription = purchaseUpdatedListener(async (purchase) => { // 1. Verify purchase on server // 2. Grant entitlement // 3. Finish transaction - await finishTransaction(purchase); + await finishTransaction({ purchase, isConsumable: false }); }); const purchaseErrorSubscription = purchaseErrorListener((error) => { @@ -152,7 +154,7 @@ interface Product { price: string; // Formatted price string priceAmount: number; // Price as number currency: string; // ISO 4217 currency code - type: 'inapp' | 'subs'; + type: 'in-app' | 'subs'; } ``` diff --git a/packages/docs/src/components/PlatformTabs.tsx b/packages/docs/src/components/PlatformTabs.tsx index 7975822b..2066dec4 100644 --- a/packages/docs/src/components/PlatformTabs.tsx +++ b/packages/docs/src/components/PlatformTabs.tsx @@ -1,57 +1,80 @@ -import { useState, useEffect, ReactNode } from 'react'; +import { useState, useEffect, useMemo, ReactNode } from 'react'; + +type Platform = 'ios' | 'android' | 'amazon' | 'horizon'; interface PlatformTabsProps { children: { ios?: ReactNode; android?: ReactNode; + amazon?: ReactNode; + horizon?: ReactNode; }; } +const PLATFORM_LABELS: Record = { + ios: 'iOS', + android: 'Android', + amazon: 'Fire OS', + horizon: 'Horizon OS', +}; + +const PLATFORM_ORDER: Platform[] = ['ios', 'android', 'horizon', 'amazon']; + +function platformFromHash(availablePlatforms: Platform[]): Platform | null { + if (typeof window === 'undefined') { + return null; + } + + const hash = window.location.hash.toLowerCase(); + return availablePlatforms.find((platform) => hash.includes(platform)) ?? null; +} + function PlatformTabs({ children }: PlatformTabsProps) { - const [activeTab, setActiveTab] = useState<'ios' | 'android'>(() => { - // Check URL hash to determine initial tab - const hash = window.location.hash.toLowerCase(); - if (hash.includes('android')) { - return 'android'; - } - return 'ios'; - }); + const availablePlatforms = useMemo( + () => PLATFORM_ORDER.filter((platform) => children[platform] !== undefined), + [ + children.ios !== undefined, + children.android !== undefined, + children.horizon !== undefined, + children.amazon !== undefined, + ] + ); + + const [activeTab, setActiveTab] = useState( + () => availablePlatforms[0] ?? 'ios' + ); useEffect(() => { - // Handle hash changes for tab switching + if (typeof window === 'undefined') { + return; + } + const handleHashChange = () => { - const hash = window.location.hash.toLowerCase(); - if (hash.includes('android')) { - setActiveTab('android'); - } else if (hash.includes('ios')) { - setActiveTab('ios'); + const next = platformFromHash(availablePlatforms); + if (next) { + setActiveTab(next); } }; + handleHashChange(); window.addEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange); - }, []); + }, [availablePlatforms]); return (
- - -
-
- {activeTab === 'ios' && children.ios} - {activeTab === 'android' && children.android} + {availablePlatforms.map((platform) => ( + + ))}
+
{children[activeTab]}
); } diff --git a/packages/docs/src/generated/version-metadata.json b/packages/docs/src/generated/version-metadata.json index 145c8d35..7f9e4ade 100644 --- a/packages/docs/src/generated/version-metadata.json +++ b/packages/docs/src/generated/version-metadata.json @@ -1,12 +1,12 @@ { "_generatedBy": "scripts/sync-versions.sh", - "expoPackageVersion": "4.3.1", - "reactNativePackageVersion": "15.3.2", - "flutterPackageVersion": "9.3.2", - "godotPackageVersion": "2.3.1", - "kmpPackageVersion": "2.3.1", + "expoPackageVersion": "4.4.0-rc.5", + "reactNativePackageVersion": "15.4.0-rc.1", + "flutterPackageVersion": "9.4.0-rc.1", + "godotPackageVersion": "2.4.0-rc.1", + "kmpPackageVersion": "2.4.0-rc.1", "mauiPackageId": "OpenIap.Maui", - "mauiPackageVersion": "1.1.1", + "mauiPackageVersion": "1.2.0-rc.1", "googleCompileSdk": "35", "googleMinSdk": "23", "googlePlayBillingVersion": "8.3.0", diff --git a/packages/docs/src/pages/docs/android-setup.tsx b/packages/docs/src/pages/docs/android-setup.tsx index c40fb755..7868a80b 100644 --- a/packages/docs/src/pages/docs/android-setup.tsx +++ b/packages/docs/src/pages/docs/android-setup.tsx @@ -29,7 +29,12 @@ function AndroidSetup() { Horizon OS Setup Guide {' '} - for Quest-specific configuration using the same Android SDK. + for Quest-specific configuration using the same Android SDK. For Amazon + Fire OS distribution, see the{' '} + + Fire OS Setup Guide + + .
diff --git a/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx b/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx index 30da4b6e..efd8af28 100644 --- a/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx @@ -45,6 +45,14 @@ function GetActiveSubscriptions() { .

+

+ Fire OS: uses the Amazon adapter's purchase-update + stream under the Android API shape. App code still passes the same + subscription SKU list and reads the same ActiveSubscription + fields; the adapter handles Amazon receipt IDs and the in-flight + purchase response correlation so examples and framework apps do not need + ad-hoc SKU alias logic. +

Signature

diff --git a/packages/docs/src/pages/docs/ecosystem.tsx b/packages/docs/src/pages/docs/ecosystem.tsx index 26a80011..74a78882 100644 --- a/packages/docs/src/pages/docs/ecosystem.tsx +++ b/packages/docs/src/pages/docs/ecosystem.tsx @@ -95,9 +95,16 @@ function Ecosystem() { > openiap-google-horizon {' '} - flavor to support Meta HorizonOS. Distributed to third party - libraries for consistent bug fixes and features. Of course, third - party libraries can also support HorizonOS thanks to this. + flavor to support Meta HorizonOS and{' '} + + openiap-google-amazon + {' '} + flavor to support Fire OS. Distributed to third party libraries for + consistent bug fixes and features.
  • - -

    Example

    -

    - OpenIAP provides example applications for both iOS and Android that - demonstrate the complete in-app purchase lifecycle. -

    - -
    -

    - Features - - # - -

    -
      -
    • - Purchase Flow - Buy consumable and non-consumable - products with verification options -
    • -
    • - Subscription Flow - Subscribe to auto-renewable - subscriptions with upgrade/downgrade support -
    • -
    • - My Purchases - View and restore previously - purchased items -
    • -
    • - Offer Code - Redeem promotional offer codes (iOS - only) -
    • -
    • - Alternative Billing - Test user-choice billing flow - (Android only) -
    • -
    -
    - - - {{ - ios: ( - <> -
    -

    - Overview - - # - -

    -
    - iOS Example App Main Screen -
    -

    Main Screen

    -

    - The home screen provides navigation to all example - features including Purchase Flow, Subscription Flow, My - Purchases, and platform-specific options like Offer Code - redemption. -

    -
    -
    -
    - -
    -

    - Location - - # - -

    -
    packages/apple/Example/
    -
    - -
    -

    - Build and Run - - # - -

    - -

    Using Xcode (Recommended)

    -
      -
    1. - Open packages/apple/Example/Martie.xcodeproj in - Xcode -
    2. -
    3. - Select your development team in Signing & Capabilities -
    4. -
    5. - Select your target device (real device recommended for IAP - testing) -
    6. -
    7. Click Run (⌘R)
    8. -
    - -

    Using Command Line

    -
    {`# Build the Swift package first
    -cd packages/apple
    -swift build
    -
    -# Build and run on simulator
    -cd Example
    -xcodebuild -project Martie.xcodeproj \\
    -  -scheme OpenIapExample \\
    -  -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \\
    -  build
    -
    -# Or build for a real device (replace with your device ID)
    -xcodebuild -project Martie.xcodeproj \\
    -  -scheme OpenIapExample \\
    -  -destination 'id=YOUR_DEVICE_UDID' \\
    -  build`}
    - -
    - ⚠️ Important: For actual in-app purchase - testing, you must run on a real device. The iOS simulator has - limited StoreKit functionality. -
    -
    - -
    -

    - Testing Purchases - - # - -

    -
    - iOS Purchase Flow Screen -
    -

    Purchase Flow

    -

    - Test purchasing consumable and non-consumable products. - Select from three verification methods: IAPKit (managed - validation), Local (StoreKit verification), or None (skip - verification). -

    -
    -
    -
      -
    1. - Sign in with a sandbox Apple ID at{' '} - - Settings → Developer → Sandbox Apple Account - -
    2. -
    3. Launch the example app
    4. -
    5. - Navigate to "Purchase Flow" or "Subscription Flow" screen -
    6. -
    7. Select a verification method (IAPKit, Local, or None)
    8. -
    9. Tap on a product to initiate a purchase
    10. -
    -
    - -
    -

    - Purchase Verification - - # - -

    -

    The example app supports three verification methods:

    - -

    IAPKit (Managed validation)

    -
      -
    • Sends purchase data to IAPKit API for verification
    • -
    • - Returns isValid: true/false and purchase state -
    • -
    • Recommended for production apps
    • -
    • - Open source (MIT) — hosted at{' '} - - kit.openiap.dev - {' '} - or self-host from{' '} - - packages/kit - -
    • -
    - -
    - Note: To use IAPKit verification, get your - project key from{' '} - - kit.openiap.dev - {' '} - and configure it in Info.plist: -
    {`# Copy the template
    -cp OpenIapExample/Info.plist.example OpenIapExample/Info.plist
    -
    -# Edit Info.plist with your project key
    -IAPKIT_API_KEY
    -openiap-kit_`}
    -
    - -

    Local

    -
      -
    • Verifies purchase locally using StoreKit APIs
    • -
    • Checks transaction verification status
    • -
    • Good for development and testing
    • -
    - -

    None

    -
      -
    • Skips verification entirely
    • -
    • Immediately finishes the transaction
    • -
    • Only for testing purchase flow UI
    • -
    -
    - -
    -

    - Git Security - - # - -

    -

    - The Info.plist file is automatically excluded - from git to avoid committing your project key. Only the{' '} - Info.plist.example template is committed. -

    -
    - -
    -

    - Troubleshooting - - # - -

    - -

    "IAPKIT_API_KEY not configured"

    -
      -
    • - Ensure Info.plist exists in{' '} - OpenIapExample/ directory -
    • -
    • - Verify the file contains the IAPKIT_API_KEY key -
    • -
    • Clean build folder (⇧⌘K) and rebuild
    • -
    - -

    Products Not Loading

    -

    - See{' '} - - iOS Setup - Common Issues - -

    -
    - -
    -

    - Subscription Upgrade - - # - -

    -
    - iOS Subscription Flow Upgrade Screen -
    -

    Subscription Flow

    -

    - Manage auto-renewable subscriptions with upgrade/downgrade - support. When upgrading, select proration mode to control - how the billing transition is handled. -

    -
    -
    -
    - -
    -

    - My Purchases - - # - -

    -
    - iOS Available Purchases Screen -
    -

    Available Purchases

    -

    - View all previously purchased items and active - subscriptions. Restore purchases to recover entitlements - after reinstalling the app or switching devices. -

    -
    -
    -
    - -
    -

    - Offer Code - - # - -

    -
    - iOS Offer Code Screen -
    -

    Offer Code Redemption

    -

    - Present the iOS offer code redemption sheet to let users - redeem promotional codes for subscriptions or products. - This feature uses the native StoreKit redemption UI. -

    -
    -
    -
    - -
    -

    - External Purchase - - # - -

    -
    - iOS Alternative Billing Screen -
    -

    Alternative Billing

    -

    - Test external purchase links that redirect users to - complete purchases outside of the App Store. This - demonstrates compliance with alternative payment - requirements in supported regions. -

    -
    -
    -
    - -
    -

    - Source Code - - # - -

    -

    - - View iOS Example on GitHub (Swift) - -

    -
    - - ), - android: ( - <> -
    -

    - Overview - - # - -

    -
    - Android Example App Main Screen -
    -

    Main Screen

    -

    - The Android version offers all core features with an - additional Alternative Billing option for testing Google - Play's user-choice billing flow. -

    -
    -
    -
    - -
    -

    - Location - - # - -

    -
    packages/google/Example/
    -
    - -
    -

    - Build and Run - - # - -

    - -

    Using Android Studio (Recommended)

    -
      -
    1. - Open packages/google directory in Android - Studio -
    2. -
    3. Wait for Gradle sync to complete
    4. -
    5. Select "Example" from the run configurations dropdown
    6. -
    7. - Select your target device (real device required for IAP - testing) -
    8. -
    9. Click Run (▶️)
    10. -
    - -

    Using Command Line

    -
    {`# Navigate to the google package
    -cd packages/google
    -
    -# Build the example app
    -./gradlew :Example:assembleDebug
    -
    -# Install on connected device
    -./gradlew :Example:installDebug
    -
    -# Or use adb directly
    -adb install Example/build/outputs/apk/debug/Example-debug.apk`}
    - -
    - ⚠️ Important: For actual in-app purchase - testing, the app must be: -
      -
    • - Installed from Google Play (internal/closed/open testing - track) -
    • -
    • - Signed with the same key as uploaded to Play Console -
    • -
    • Tested with a license tester account
    • -
    -
    -
    - -
    -

    - Testing Purchases - - # - -

    -
    - Android Purchase Flow Screen -
    -

    Purchase Flow

    -

    - Purchase products using Google Play Billing. The - verification dropdown lets you choose between IAPKit - server verification, local validation, or skipping - verification entirely. -

    -
    -
    -
      -
    1. - Add your Google account as a license tester in{' '} - Play Console → Setup → License testing -
    2. -
    3. Download the app from the Play Store test track
    4. -
    5. - Launch the app and go to "Purchase Flow" or "Subscription - Flow" -
    6. -
    7. Select a verification method (IAPKit, Local, or None)
    8. -
    9. Tap on a product to initiate a purchase
    10. -
    -
    - -
    -

    - Purchase Verification - - # - -

    -

    The example app supports three verification methods:

    - -

    IAPKit (Managed validation)

    -
      -
    • Sends purchase data to IAPKit API for verification
    • -
    • - Returns isValid: true/false and purchase state -
    • -
    • Recommended for production apps
    • -
    • - Open source (MIT) — hosted at{' '} - - kit.openiap.dev - {' '} - or self-host from{' '} - - packages/kit - -
    • -
    - -
    - Note: To use IAPKit verification, get your - project key from{' '} - - kit.openiap.dev - {' '} - and configure it in local.properties: -
    {`# Copy the template
    -cp local.properties.example local.properties
    -
    -# Add your project key
    -iapkit.api.key=openiap-kit_`}
    -
    - -

    Local

    -
      -
    • - Verifies purchase locally using Google Play Billing APIs -
    • -
    • Checks purchase state
    • -
    • Good for development and testing
    • -
    - -

    None

    -
      -
    • Skips verification entirely
    • -
    • Immediately finishes the transaction
    • -
    • Only for testing purchase flow UI
    • -
    -
    - -
    -

    - How Project Key is Loaded - - # - -

    -

    - The project key from local.properties is injected - into the app via BuildConfig during the build - process. The Example app's build.gradle.kts{' '} - includes: -

    -
    {`// In Example/build.gradle.kts
    -val localProperties = Properties()
    -val localPropertiesFile = rootProject.file("local.properties")
    -if (localPropertiesFile.exists()) {
    -    localProperties.load(localPropertiesFile.inputStream())
    -}
    -
    -android {
    -    defaultConfig {
    -        buildConfigField(
    -            "String",
    -            "IAPKIT_API_KEY",
    -            ""${'$'}{localProperties.getProperty("iapkit.api.key", "")}""
    -        )
    -    }
    -}`}
    -
    - -
    -

    - Git Security - - # - -

    -

    - The local.properties file is automatically - excluded from git (standard Android convention). Only the{' '} - local.properties.example template is committed. -

    -
    - -
    -

    - Troubleshooting - - # - -

    - -

    "IAPKit API Key not configured"

    -
      -
    • - Ensure local.properties exists in{' '} - packages/google/ directory -
    • -
    • - Verify it contains{' '} - iapkit.api.key=openiap-kit_<your-key> -
    • -
    • - Clean and rebuild:{' '} - ./gradlew clean :Example:assembleDebug -
    • -
    - -

    Products Not Loading

    -

    - See{' '} - - Android Setup - Common Issues - -

    -
    - -
    -

    - Subscription Flow - - # - -

    -
    - Android Subscription Flow Screen -
    -

    Subscription Flow

    -

    - Subscribe to auto-renewable subscriptions via Google Play. - View available subscription tiers and initiate new - subscriptions with selected verification options. -

    -
    -
    -
    - -
    -

    - Subscription Upgrade - - # - -

    -
    - Android Subscription Flow Upgrade Screen -
    -

    Upgrade/Downgrade

    -

    - Upgrade or downgrade existing subscriptions with proration - mode selection. Choose how the remaining balance should be - applied to the new subscription tier. -

    -
    -
    -
    - -
    -

    - My Purchases - - # - -

    -
    - Android Available Purchases Screen -
    -

    Available Purchases

    -

    - View purchased products and active subscriptions from - Google Play. Restore purchases to sync entitlements across - devices or after app reinstallation. -

    -
    -
    -
    - -
    -

    - Redeem Offer Code - - # - -

    -
    - Android Redeem Offer Code Screen -
    -

    Offer Code Redemption

    -

    - Open the Google Play redemption flow where users can enter - promo codes. The app launches the Play Store's native code - redemption interface. -

    -
    -
    -
    - -
    -

    - Source Code - - # - -

    -

    - - View Android Example on GitHub (Kotlin) - -

    -
    - - ), - }} -
    - -
    - - Framework Examples - -

    - Each framework library includes a working example app that - demonstrates how to integrate OpenIAP. Use these as a starting point - for your own implementation. -

    - -
    -
    -

    - - React Native (CLI) - -

    -

    - Bare React Native app with full IAP flow. -

    -
    {`cd libraries/react-native-iap/example
    -yarn install
    -yarn ios`}
    -
    - -
    -

    - - Expo IAP - -

    -

    - Expo-managed app with IAP integration. -

    -
    {`cd libraries/expo-iap/example
    -bun install
    -bun ios`}
    -
    - -
    -

    - - Flutter - -

    -

    - Flutter example with full purchase and subscription flow. -

    -
    {`cd libraries/flutter_inapp_purchase/example
    -flutter run`}
    -
    - -
    -

    - - Godot - -

    -

    - Godot project with in-app purchase demo scene. -

    -
    {`# Open in Godot editor
    -libraries/godot-iap/Example/project.godot`}
    -
    - -
    -

    - - Kotlin Multiplatform - -

    -

    - KMP example targeting Android (Gradle) and iOS (Xcode). -

    -
    {`cd libraries/kmp-iap/example
    -
    -# Android: build with Gradle
    -./gradlew :composeApp:assembleDebug
    -
    -# iOS: open iosApp/ in Xcode`}
    -
    -
    -
    - - ); -} - -export default Example; +export { default } from './examples'; diff --git a/packages/docs/src/pages/docs/examples/StoreExampleTemplate.tsx b/packages/docs/src/pages/docs/examples/StoreExampleTemplate.tsx new file mode 100644 index 00000000..1d2753bb --- /dev/null +++ b/packages/docs/src/pages/docs/examples/StoreExampleTemplate.tsx @@ -0,0 +1,669 @@ +import { Fragment, type ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; +import VideoSlot, { type VideoVariant } from './VideoSlot'; + +type VideoKey = + | 'overview' + | 'purchase' + | 'subscription' + | 'available' + | 'verification'; + +interface ProofPoint { + area: string; + proof: ReactNode; + where: ReactNode; +} + +interface ReadinessItem { + item: string; + expected: ReactNode; +} + +interface VerificationItem { + part: string; + explanation: ReactNode; +} + +interface StoreVideoCopy { + title: string; + description: string; + src?: string; + poster?: string; + variants?: VideoVariant[]; +} + +export interface StoreExampleConfig { + title: string; + seo: { + title: string; + description: string; + path: string; + keywords: string; + }; + storeName: string; + sourcePath: string; + sourceHref: string; + intro: ReactNode; + goal: ReactNode; + overview: ReactNode; + overviewImage?: { + src: string; + alt: string; + }; + proofPoints: ProofPoint[]; + productSkus: string[]; + subscriptionSkus: string[]; + purchaseRequestShape: ReactNode; + subscriptionRequestShape: ReactNode; + purchaseUpdateText: ReactNode; + finishText: ReactNode; + subscriptionManagementText: ReactNode; + availablePurchasesText: ReactNode; + verificationIntro: ReactNode; + verificationItems: VerificationItem[]; + readinessTitle: string; + readinessIntro: ReactNode; + readinessItems: ReadinessItem[]; + frameworkIntro: ReactNode; + frameworkNote: ReactNode; + frameworkVerificationApi: { + label: string; + to: string; + }; + frameworkSnippet: string; + buildCommand: string; + videos: Record; +} + +function CodeLink({ to, children }: { to: string; children: string }) { + return ( + + {children} + + ); +} + +function CodeList({ values }: { values: string[] }) { + return ( + <> + {values.map((value, index) => ( + + {index > 0 ? ', ' : null} + {value} + + ))} + + ); +} + +function ExampleVideo({ + video, + fallbackTitle, +}: { + video: StoreVideoCopy; + fallbackTitle: string; +}) { + return ( + + ); +} + +function StoreExampleTemplate({ config }: { config: StoreExampleConfig }) { + useScrollToHash(); + + return ( +
    + +

    {config.title}

    +

    {config.intro}

    +
    + Goal for this walkthrough: {config.goal} +
    + +
    + + Demo Overview + +

    {config.overview}

    + {config.overviewImage ? ( + {config.overviewImage.alt} + ) : null} +
      +
    • + Confirm the walkthrough is using the intended store target before + touching a purchase button. +
    • +
    • + Record each action separately: purchase flow, subscription flow, + available purchases, and verification. +
    • +
    • + Keep the narration focused on the shared OpenIAP lifecycle: + initialize, fetch, request, verify, finish, and refresh. +
    • +
    +
    + +
    + + Proof Points + +

    + These are the details the video should make obvious before the article + moves into framework code. They keep the page store-first instead of + reading like a generic UI tour. +

    + + + + + + + + + + {config.proofPoints.map((point) => ( + + + + + + ))} + +
    AreaWhat this target provesWhere it appears
    {point.area}{point.proof}{point.where}
    +
    + +
    + + Purchase Flow + +
    + +
    +

    + This menu covers consumables and non-consumables. The screen calls{' '} + + initConnection + + , then fetches the example in-app SKU list with{' '} + fetchProducts. + In the video, show the verification selector first, then the + product rows, and then the Buy action. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StepCode pathWhat to explain
    Load products + + fetchProducts + {' '} + with the in-app product type. + + Store catalog data is normalized into OpenIAP products such + as . +
    Start purchase + + requestPurchase + {' '} + with {config.purchaseRequestShape}. + + The button launches the store purchase sheet for one + selected product and then waits for a purchase update. +
    Handle update{config.purchaseUpdateText} + This is where the app waits for the store result instead of + treating the button tap itself as proof of purchase. +
    Verify and finish{config.finishText} + Consumables are consumed; non-consumables are fulfilled. + Access should be unlocked only after the verification result + is accepted. +
    +
    + The store-specific work belongs in the adapter. The app code + continues to fetch, request, verify, finish, and refresh using the + OpenIAP lifecycle. +
    +
    +
    +
    + +
    + + Subscription Flow + +
    + +
    +

    + This menu demonstrates recurring products. The example checks + current subscription state, fetches product metadata, and then + launches the subscription purchase request. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StepCode pathWhat to explain
    Check state + + getActiveSubscriptions + {' '} + with the subscription SKU list. + + Separate "what is currently active" from "what can be + purchased" before showing the offer rows. +
    Load offers + + fetchProducts + {' '} + with the subscription product type. + + Subscription products such as{' '} + are displayed + as normalized OpenIAP subscription products. +
    Start subscription + + requestPurchase + {' '} + with {config.subscriptionRequestShape}. + {config.subscriptionManagementText}
    Finalize{config.finishText} + Subscriptions are not consumed like consumables. Finishing + records store fulfillment while the app keeps entitlement + state driven by verified subscription status. +
    +
    +
    +
    + +
    + + Available Purchases + +
    + +
    +

    {config.availablePurchasesText}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StepCode pathWhat to explain
    Refresh + + getAvailablePurchases + + . + + The app can rebuild entitlement state after launch or after + a network/store reconnect. +
    Restore + + restorePurchases + {' '} + or{' '} + + getAvailablePurchases + + . + + This is the recovery path for reinstalls, device changes, + and account changes. +
    Group purchases + The screen groups active subscriptions, owned + non-consumables, and pending consumables. + + The split gives the article clear talking points: active + access, permanent ownership, and transactions that still + need fulfillment. +
    Finish unfinished + Unfinished rows call{' '} + + finishTransaction + {' '} + after validation. + + Restore and verification are not enough by themselves: the + store transaction must still be fulfilled or consumed. +
    +
    +
    +
    + +
    + + Purchase Verification + +
    + +
    +

    {config.verificationIntro}

    + + + + + + + + + {config.verificationItems.map((item) => ( + + + + + ))} + +
    PartWhat to explain
    {item.part}{item.explanation}
    +
    + Production rule +

    + Verification is the gate; finishing is the receipt lifecycle + cleanup. Do not call finishTransaction as the only + proof that content should be unlocked. +

    +
    +
    +
    +
    + +
    + + {config.readinessTitle} + +

    {config.readinessIntro}

    + + + + + + + + + {config.readinessItems.map((item) => ( + + + + + ))} + +
    ChecklistExpected state
    {item.item}{item.expected}
    +
    + +
    + + Framework Handoff + +

    {config.frameworkIntro}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FlowShared APIFramework note
    Product and subscription loading + + fetchProducts + + + Available in Expo, React Native, Flutter, Kotlin Multiplatform, + .NET MAUI, and Godot with the same OpenIAP operation name. +
    Starting a purchase + + requestPurchase + + + The request shape changes by language, but the lifecycle stays + the same: pass a SKU, wait for the purchase update, then verify. +
    Restore and entitlement recovery + + getAvailablePurchases + + + Use this after launch, after restore, and after finishing a + transaction to rebuild local entitlement state. +
    Managed verification + + {config.frameworkVerificationApi.label} + + {config.frameworkNote}
    Final fulfillment + + finishTransaction + + + Finish after verification. Consumables are consumed; owned + products and subscriptions are fulfilled without consuming. +
    + {config.frameworkSnippet} +
    + +
    + + Video Script + +

    + Keep each clip short, but make the action and the evidence visible. + This structure gives the article/video a clean sequence instead of a + raw list of recordings. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ClipOpen withClose on
    Overview + Show the store target and explain that this is the{' '} + {config.storeName} pass of the shared OpenIAP example flow. + + Land on the three menus: purchase flow, subscription flow, and + available purchases. +
    Purchase FlowShow the verification selector, then the product rows. + Show the purchase result or the exact tester/catalog requirement + if the store sheet cannot proceed. +
    Subscription Flow + Show monthly and annual subscription rows loaded through + OpenIAP. + Show the store-specific subscription management language.
    Available PurchasesTap refresh or restore. + Show restored count, grouped purchase rows, or the empty state + with the correct store account wording. +
    Purchase VerificationShow the configured verification option. + Explain what receipt/token leaves the client and why the app + finishes only after a verified result. +
    +
    + +
    + + Build and Run + +

    + Source: {config.sourcePath} +

    + {config.buildCommand} +
    + +
    + + Source + +

    + + View {config.title} on GitHub + +

    +
    +
    + ); +} + +export default StoreExampleTemplate; diff --git a/packages/docs/src/pages/docs/examples/VideoSlot.tsx b/packages/docs/src/pages/docs/examples/VideoSlot.tsx new file mode 100644 index 00000000..0d9b9eb0 --- /dev/null +++ b/packages/docs/src/pages/docs/examples/VideoSlot.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; + +export interface VideoVariant { + id: string; + label: string; + title: string; + description: string; + src?: string; + poster?: string; +} + +interface VideoSlotProps { + title: string; + description: string; + src?: string; + poster?: string; + variants?: VideoVariant[]; +} + +function VideoSlot({ + title, + description, + src, + poster, + variants, +}: VideoSlotProps) { + const [activeVariantId, setActiveVariantId] = useState( + variants?.[0]?.id ?? '' + ); + const activeVariant = + variants?.find((variant) => variant.id === activeVariantId) ?? + variants?.[0]; + const activeTitle = activeVariant?.title ?? title; + const activeDescription = activeVariant?.description ?? description; + const activeSrc = activeVariant?.src ?? src; + const activePoster = activeVariant?.poster ?? poster; + + return ( +
    + {variants?.length ? ( +
    + {variants.map((variant) => ( + + ))} +
    + ) : null} + {activeSrc ? ( + + ) : ( +
    + Video placeholder +
    + )} +
    +

    {activeTitle}

    +

    {activeDescription}

    +
    +
    + ); +} + +export default VideoSlot; diff --git a/packages/docs/src/pages/docs/examples/android.tsx b/packages/docs/src/pages/docs/examples/android.tsx new file mode 100644 index 00000000..13681590 --- /dev/null +++ b/packages/docs/src/pages/docs/examples/android.tsx @@ -0,0 +1,336 @@ +import StoreExampleTemplate, { + type StoreExampleConfig, +} from './StoreExampleTemplate'; + +const ANDROID_VIDEO_BASE = '/examples/google/videos'; +const ANDROID_POSTER = '/examples/google/home.webp'; +const ANDROID_VIDEO_VERSION = 'v=20260527-google-play-english'; + +function androidVideo(fileName: string) { + return `${ANDROID_VIDEO_BASE}/${fileName}?${ANDROID_VIDEO_VERSION}`; +} + +export const ANDROID_CONFIG: StoreExampleConfig = { + title: 'Android Example', + seo: { + title: 'Android Example', + description: + 'OpenIAP Google Play Billing example walkthrough for Android purchases, subscriptions, restore flows, and IAPKit verification.', + path: '/docs/example/android', + keywords: + 'OpenIAP Android example, Google Play Billing example, Android IAP video, Play purchase verification', + }, + storeName: 'Google Play Billing', + sourcePath: 'packages/google/Example/', + sourceHref: + 'https://github.com/hyodotdev/openiap/tree/main/packages/google/Example', + intro: ( + <> + The Android example is the Kotlin/Compose app compiled with the Play + flavor. It links Google Play Billing, uses Play Console product IDs, maps + purchase tokens into the OpenIAP purchase model, and verifies purchases + through IAPKit when managed verification is enabled. + + ), + goal: ( + <> + prove the app is using Google Play Billing end to end: Play Console + catalog lookup, purchase launch, purchase update handling, purchaseToken + verification, restore, and acknowledgement or consumption. + + ), + overview: ( + <> + This page uses the same shared walkthrough structure as Fire OS and + Horizon OS, with clips recorded from the Google Play Billing build so the + article can compare store behavior without changing the application flow. + + ), + proofPoints: [ + { + area: 'Adapter selection', + proof: ( + <> + The Play flavor loads the Google Play Billing module instead of the + Horizon or Amazon modules. + + ), + where: 'Home badge, build flavor, and Play-specific setup.', + }, + { + area: 'Billing connection', + proof: ( + <> + initConnection connects the Play Billing client before + products or purchases are queried. + + ), + where: 'First load of each purchase screen.', + }, + { + area: 'Catalog', + proof: ( + <> + fetchProducts maps Play ProductDetails into + OpenIAP product and subscription product types. + + ), + where: 'Product and subscription rows.', + }, + { + area: 'Purchase token', + proof: ( + <> + Play purchaseToken is carried through{' '} + PurchaseAndroid for verification and transaction finish. + + ), + where: 'Purchase details and verification payload.', + }, + { + area: 'Fulfillment', + proof: ( + <> + finishTransaction acknowledges owned products and + consumes consumables after validation. + + ), + where: 'Purchase flow and unfinished transaction restore flow.', + }, + ], + productSkus: [ + 'dev.hyo.martie.10bulbs', + 'dev.hyo.martie.30bulbs', + 'dev.hyo.martie.certified', + ], + subscriptionSkus: ['dev.hyo.martie.premium', 'dev.hyo.martie.premium_year'], + purchaseRequestShape: ( + <> + a Google purchase request, for example google: {'{ skus }'} + + ), + subscriptionRequestShape: ( + <> + a Google subscription request with SKU and offer token data when the + selected Play offer requires it + + ), + purchaseUpdateText: ( + <> + The example watches the latest PurchaseAndroid update from + the Play Billing listener. + + ), + finishText: ( + <> + Verify, call finishTransaction, then refresh{' '} + getAvailablePurchases. + + ), + subscriptionManagementText: ( + <> + The clip should call out Play subscription offers, base plans, replacement + parameters, and Play subscription management. + + ), + availablePurchasesText: ( + <> + This menu is the Google Play entitlement recovery story. It should show + active purchases returned from Play Billing through{' '} + getAvailablePurchases and restore actions. + + ), + verificationIntro: ( + <> + This clip shows where the app switches from local Play Billing state to + managed validation. For IAPKit, Android sends the Play purchase token + through the Google payload. + + ), + verificationItems: [ + { + part: 'IAPKit key', + explanation: ( + <> + Configure the project key before recording managed verification. The + client should not be the source of truth for entitlement grants. + + ), + }, + { + part: 'Google payload', + explanation: ( + <> + Use verifyPurchaseWithProvider with an{' '} + iapkit.google payload containing the Play purchase token. + + ), + }, + { + part: 'Unlock decision', + explanation: ( + <> + Grant access only after the verified response matches the expected + product and account state. + + ), + }, + { + part: 'Finish order', + explanation: ( + <> + Acknowledge or consume only after verification and entitlement grant. + + ), + }, + ], + readinessTitle: 'Android Readiness', + readinessIntro: ( + <> + Before publishing the article or sharing the video, verify these items so + the demo reads as a real Google Play Billing integration. + + ), + readinessItems: [ + { + item: 'Build flavor', + expected: ( + <> + Use :Example:assemblePlayDebug so the app links Google + Play Billing and loads the Play OpenIAP module. + + ), + }, + { + item: 'Play Console products', + expected: ( + <> + The in-app products and subscriptions must exist in Play Console with + IDs matching the example SKU lists. + + ), + }, + { + item: 'Tester account', + expected: ( + <> + Use a Play license tester or internal testing account that can open + the purchase sheet for the uploaded package/signature. + + ), + }, + { + item: 'Subscription offers', + expected: ( + <> + Base plans and offer tokens should be active so the subscription clip + can show realistic offer metadata. + + ), + }, + { + item: 'Verification key', + expected: ( + <>Configure the IAPKit key before recording the verification clip. + ), + }, + ], + frameworkIntro: ( + <> + The Android video should prove the Play Billing layer first. App teams can + then move the same lifecycle into Expo, React Native, Flutter, Kotlin + Multiplatform, .NET MAUI, and Godot. + + ), + frameworkVerificationApi: { + label: 'verifyPurchaseWithProvider', + to: '/docs/features/validation#verify-purchase-with-provider', + }, + frameworkNote: ( + <> + For Google Play, pass the IAPKit Google payload with the Play purchase + token. + + ), + frameworkSnippet: `import { + type Purchase, + fetchProducts, + requestPurchase, + getAvailablePurchases, + finishTransaction, + verifyPurchaseWithProvider, +} from 'expo-iap'; +// React Native uses the same top-level API names from 'react-native-iap'. + +const products = await fetchProducts({ + skus: ['dev.hyo.martie.10bulbs'], + type: 'in-app', +}); +const [product] = products ?? []; +if (!product) throw new Error('Play product not found'); + +await requestPurchase({ + request: { google: { skus: [product.id] } }, + type: 'in-app', +}); + +async function onPurchaseUpdated(purchase: Purchase) { + const result = await verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + google: { purchaseToken: purchase.purchaseToken ?? purchase.id }, + }, + }); + + if (result.iapkit?.isValid) { + await finishTransaction({ purchase, isConsumable: true }); + await getAvailablePurchases(); + } +}`, + buildCommand: `cd packages/google +./gradlew :Example:assemblePlayDebug +./gradlew :Example:installPlayDebug + +adb shell monkey -p dev.hyo.martie -c android.intent.category.LAUNCHER 1`, + videos: { + overview: { + title: 'Overview', + description: + 'Google Play Billing home screen context and feature navigation for the shared walkthrough.', + }, + purchase: { + title: 'Purchase Flow', + description: + 'Product fetch, Play purchase sheet, approved tester purchase, and finish callback.', + src: androidVideo('google-inapp.mp4'), + poster: ANDROID_POSTER, + }, + subscription: { + title: 'Subscription Flow', + description: + 'Subscription products, Play subscription sheet, approved tester subscription, and active subscription state.', + src: androidVideo('google-subscription.mp4'), + poster: ANDROID_POSTER, + }, + available: { + title: 'Available Purchases', + description: + 'Restored active subscription and owned non-consumable rows returned by getAvailablePurchases.', + src: androidVideo('google-available-purchases.mp4'), + poster: ANDROID_POSTER, + }, + verification: { + title: 'Purchase Verification', + description: + 'Verification provider selector and IAPKit key configuration surface for Play purchaseToken validation.', + src: androidVideo('google-verification.mp4'), + poster: ANDROID_POSTER, + }, + }, +}; + +function AndroidExample() { + return ; +} + +export default AndroidExample; diff --git a/packages/docs/src/pages/docs/examples/data.ts b/packages/docs/src/pages/docs/examples/data.ts new file mode 100644 index 00000000..17aeb9a6 --- /dev/null +++ b/packages/docs/src/pages/docs/examples/data.ts @@ -0,0 +1,55 @@ +export interface ExampleTarget { + path: string; + label: string; + store: string; + sourcePath: string; + summary: string; + status: 'ready' | 'planned'; +} + +export const EXAMPLE_TARGETS: ExampleTarget[] = [ + { + path: '/docs/example', + label: 'Apple', + store: 'App Store / StoreKit 2', + sourcePath: 'packages/apple/Example/', + summary: + 'StoreKit 2 walkthrough with recorded iOS purchase, subscription, restore, and verification flows.', + status: 'ready', + }, + { + path: '/docs/example', + label: 'Google', + store: 'Google Play Billing', + sourcePath: 'packages/google/Example/', + summary: + 'Google Play Billing walkthrough with recorded Android purchase, subscription, restore, and verification flows.', + status: 'ready', + }, + { + path: '/docs/example', + label: 'Horizon OS', + store: 'Meta Horizon Billing', + sourcePath: 'packages/google/Example/', + summary: + 'Meta Horizon Billing walkthrough with recorded Quest purchase, subscription, restore, and verification flows.', + status: 'ready', + }, + { + path: '/docs/example', + label: 'Fire OS', + store: 'Amazon Appstore IAP', + sourcePath: 'packages/google/Example/', + summary: + 'Complete walkthrough for the Amazon flavor on a real Fire OS tablet.', + status: 'ready', + }, +]; + +export const VIDEO_STEPS = [ + 'Overview', + 'In-app purchase', + 'Subscription', + 'Available purchases', + 'Verification', +]; diff --git a/packages/docs/src/pages/docs/examples/fireos.tsx b/packages/docs/src/pages/docs/examples/fireos.tsx new file mode 100644 index 00000000..e672aa57 --- /dev/null +++ b/packages/docs/src/pages/docs/examples/fireos.tsx @@ -0,0 +1,965 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; +import { IAPKIT_URL, trackIapKitClick } from '../../../lib/config'; +import VideoSlot from './VideoSlot'; + +const FIREOS_VIDEO_BASE = '/examples/amazon/videos'; +const FIREOS_POSTER = '/examples/amazon/home.webp'; +const FIREOS_VIDEO_VERSION = 'v=20260526-corrected'; + +function fireOsVideo(fileName: string) { + return `${FIREOS_VIDEO_BASE}/${fileName}?${FIREOS_VIDEO_VERSION}`; +} + +function CodeLink({ to, children }: { to: string; children: string }) { + return ( + + {children} + + ); +} + +function FireOSExample() { + useScrollToHash(); + + return ( +
    + +

    Fire OS Example

    +

    + The Fire OS example is the shared Android Kotlin/Compose app compiled + with the amazon flavor. It links the Amazon Appstore SDK, + uses Amazon product IDs, maps Amazon receipt IDs into the OpenIAP + purchase model, and verifies purchases through IAPKit when managed + verification is enabled. +

    +
    + Goal for the Amazon walkthrough: prove the app is using + the Amazon Appstore adapter end to end: product catalog lookup, one-SKU + purchase launch, purchase update handling, Amazon receipt ID based + verification, restore, and fulfillment. +
    + +
    + + Demo Overview + +

    + Start the article/video with the store context: this is the{' '} + amazon flavor running on a real Fire OS tablet, using the + same OpenIAP screen flow that the Apple, Google, Horizon OS, and Fire + OS example pages will share. +

    +
    + +
    +

    + Use this clip to orient viewers before touching any purchase + button. The home screen confirms the Amazon Appstore adapter is + selected and shows the three menus that matter for the demo: + purchase flow, subscription flow, and available purchases. +

    + + + + + + + + + + + + + + + + + + + + + +
    MomentWhat to call out
    Launch + The badge reads Amazon Fire OS, proving the + Amazon flavor is installed instead of the Google or Horizon + flavor. +
    Navigation + Each menu is a focused recording target: one for products, + one for subscriptions, and one for restore/entitlement + recovery. +
    Shared workflow + The native sample uses OpenIapStore, but the + same operation names are exposed by the framework SDKs. +
    +
    +
    +
    + +
    + + Amazon Proof Points + +

    + These are the details the video should make obvious before the article + moves into framework code. They map directly to the Amazon flavor + implementation in packages/google/openiap/src/amazon and + the Compose screens under packages/google/Example. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    AreaWhat Fire OS provesWhere it appears
    Adapter selection + The amazon flavor loads the Amazon module instead + of Play Billing or Horizon. + Home badge and Fire OS copy.
    User context + + initConnection + {' '} + registers Amazon IAP callbacks and requests Amazon user data for + receipt verification. + First load of each purchase screen.
    Catalog + + fetchProducts + {' '} + maps Amazon consumables, entitlements, and subscriptions into + OpenIAP product types. + Product and subscription rows.
    Receipt identity + Amazon receiptId is exposed through the Android + purchase shape as the purchase token/id used by downstream + verification and fulfillment. + Purchase details and verification payload.
    Fulfillment + + finishTransaction + {' '} + calls Amazon fulfillment with the receipt ID after validation. + Purchase flow and unfinished transaction restore flow.
    +
    + +
    + + Purchase Flow + +
    + +
    +

    + This menu covers consumables and non-consumables. The screen calls{' '} + + initConnection + + , then fetches IapConstants.INAPP_SKUS with{' '} + fetchProducts. + In the video, show the verification selector first, then the + product rows, and then the Buy action. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StepCode pathWhat to explain
    Load products + + fetchProducts + {' '} + with ProductQueryType.InApp. + + Amazon Appstore product data is normalized into OpenIAP + products such as dev.hyo.martie.10bulbs,{' '} + dev.hyo.martie.30bulbs, and{' '} + dev.hyo.martie.certified. +
    Start purchase + + requestPurchase + {' '} + with RequestPurchaseProps.Request.Purchase. + + Amazon accepts one SKU per purchase request, so each row + launches a single Amazon Appstore purchase sheet. +
    Handle update + The screen watches currentPurchase and the + latest PurchaseAndroid. + + This is where the app waits for the store result instead of + treating the button tap itself as proof of purchase. +
    Verify and finish + Verify, then call{' '} + + finishTransaction + {' '} + and refresh{' '} + + getAvailablePurchases + + . + + Consumables are consumed; non-consumables are fulfilled. + Access should be unlocked only after the verification result + is accepted. +
    +

    + The demo can keep moving in test-mode recordings, but a production + app should not unlock premium access after a failed IAPKit or + server verification response. +

    +
    + For narration, call out that Amazon product requests are still + regular OpenIAP in-app requests. The store-specific + work is hidden inside the Amazon adapter; the app code continues + to fetch, request, verify, finish, and refresh. +
    +
    +
    +
    + +
    + + Subscription Flow + +
    + +
    +

    + This menu demonstrates recurring products. The Fire OS build loads + the Amazon-compatible subscription IDs from{' '} + IapConstants.getSubscriptionSkus(), checks current + subscription state, fetches product metadata, and then launches + the subscription purchase request. The screen does not need an + Amazon-only receipt alias layer; it uses the same OpenIAP + subscription calls as the Play, Horizon, Expo, and React Native + examples. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StepCode pathWhat to explain
    Check state + + getActiveSubscriptions + {' '} + with the subscription SKU list. + + The example logs active status before rendering offers, so + the video can separate "what is currently active" from "what + can be purchased". Fire OS returns the same{' '} + ActiveSubscription shape, with Amazon receipt + handling contained inside the adapter. +
    Load offers + + fetchProducts + {' '} + with ProductQueryType.Subs. + + Monthly and annual products such as{' '} + dev.hyo.martie.premium and{' '} + dev.hyo.martie.premium_year are displayed as + normalized subscription products. +
    Start subscription + + requestPurchase + {' '} + with RequestPurchaseProps.Request.Subscription. + + The same screen shape is used for Play, Horizon, and Amazon, + but the Fire OS build launches the Amazon Appstore purchase + sheet. The adapter correlates the in-flight response with + the requested SKU so the app keeps filtering by its normal + subscription product IDs. +
    Finalize + Verify the purchase, call{' '} + + finishTransaction + + , then refresh{' '} + + getAvailablePurchases + + . + + Subscriptions are not consumed like bulbs. Finishing records + the store fulfillment while the app keeps entitlement state + driven by verified subscription status. +
    +

    + Live subscription purchases require an Amazon Appstore tester, + matching catalog products, and an eligible signed-in Amazon + account on the Fire OS device. +

    +
    + The subscription clip should show that the Fire OS build uses + Amazon-specific management language. Avoid Play Store cancellation + copy when recording the Amazon walkthrough. +
    +
    +
    +
    + +
    + + Available Purchases + +
    + +
    +

    + This menu is the entitlement recovery story. On entry, on refresh, + and on restore, the screen calls{' '} + + getAvailablePurchases + + . The Amazon adapter backs that with purchase updates from the + Amazon Appstore account. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StepCode pathWhat to explain
    Refresh + + getAvailablePurchases + + . + + The app can rebuild entitlement state after launch or after + a network/store reconnect. +
    Restore + The restore button also uses{' '} + + getAvailablePurchases + {' '} + and reports the restored count. + + This is the Fire OS recovery path for reinstalls, device + changes, and Amazon account changes. +
    Group purchases + The screen groups active subscriptions, owned + non-consumables, and pending consumables. + + The split gives the article clear talking points: active + access, permanent ownership, and transactions that still + need fulfillment. +
    Finish unfinished + Unfinished rows call{' '} + + finishTransaction + {' '} + after validation. + + This shows why restore and verification are not enough by + themselves: the store transaction must still be fulfilled or + consumed. +
    +

    + When the list is empty, the explanation is still useful: the + screen is demonstrating the same restore and entitlement recovery + API that app teams should run after login, reinstall, account + switch, and transaction finish. +

    +
    +
    +
    + +
    + + IAPKit Verification + +
    + +
    +

    + This clip supports both the purchase and subscription menus. It + shows where the app switches from a local demo state to managed + validation through IAPKit and Amazon Receipt Verification Service. +

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    PartWhat to explain
    API key + Add iapkit.api.key before building when you + want managed verification against{' '} + + kit.openiap.dev + + . +
    Amazon payload + Provider-level Fire OS verification uses{' '} + + verifyPurchaseWithProvider + {' '} + with an iapkit.amazon payload. The Amazon + native provider requires a receiptId and can + resolve userId from Amazon user data when the + caller does not pass one. +
    Unlock decision + Grant access only after verification succeeds; do not trust + a client-only premium flag or a button tap. +
    Finish order + Verification should happen before{' '} + + finishTransaction + + , because finishing tells the store that the purchase was + fulfilled. +
    +
    + Production rule +

    + Verification is the gate; finishing is the receipt lifecycle + cleanup. Do not call finishTransaction as the only + proof that content should be unlocked. +

    +
    +
    +
    +
    + +
    + + Amazon Readiness + +

    + Before publishing the article or sharing the video with Amazon, verify + these items so the demo reads as an Amazon Appstore integration rather + than a generic Android screen recording. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ChecklistExpected state
    Build flavor + Use :Example:assembleAmazonDebug so the app links + the Amazon Appstore SDK and loads the Amazon OpenIAP module. +
    Catalog IDs + Amazon catalog entries should match the example SKUs: + consumables dev.hyo.martie.10bulbs /{' '} + dev.hyo.martie.30bulbs, entitlement{' '} + dev.hyo.martie.certified, and subscriptions{' '} + dev.hyo.martie.premium /{' '} + dev.hyo.martie.premium_year. If Amazon App Tester + or a subscription group uses another internal SKU, update the + catalog instead of adding app-side alias code. +
    Subscription grouping + Amazon subscriptions can be organized through store-side groups + and terms, similar to the way Apple and Google structure + subscription families. The OpenIAP example should still receive + the requested SKU as productId and the active plan + as currentPlanId, so the same entitlement code + works across native Android, Expo, and React Native. +
    Tester account + The Fire OS tablet must be signed in with an Amazon account that + can exercise the configured Appstore test catalog. +
    Verification key + Set iapkit.api.key in{' '} + packages/google/local.properties before recording + the IAPKit clip. +
    Sandbox receipts + Use the IAPKit Amazon payload with sandbox: true{' '} + for tester receipts so Amazon RVS validation is routed to the + correct environment. +
    Kit entitlement identity + Kit verification stores and checks entitlements from the + verified receipt. Keep the Amazon receipt SKU aligned with the + app-facing OpenIAP SKU, otherwise server-side entitlement checks + can disagree with the client purchase response. Treat Kit or + store restore APIs as the source of truth for subscription + status. +
    +
    + +
    + + Framework Handoff + +

    + The Fire OS video is recorded from the native Kotlin sample so the + Amazon adapter behavior is visible. App teams can move the same + lifecycle into framework SDKs without inventing new operation names. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FlowShared APIFramework note
    Product and subscription loading + + fetchProducts + + + Available in Expo, React Native, Flutter, Kotlin Multiplatform, + .NET MAUI, and Godot with the same OpenIAP operation name. +
    Starting a purchase + + requestPurchase + + + The request shape changes by language, but the flow stays the + same: pass one Amazon SKU, wait for the purchase update, then + verify. +
    Restore and entitlement recovery + + getAvailablePurchases + + + Use this after launch, after restore, and after finishing a + transaction to rebuild local entitlement state. +
    Managed verification + + verifyPurchaseWithProvider + + + For Amazon, pass the IAPKit Amazon payload with the receipt ID; + the provider path can resolve the Amazon user ID when supported + by the platform adapter. +
    Final fulfillment + + finishTransaction + + + Finish after verification. Consumables are consumed; owned + products and subscriptions are fulfilled without consuming. +
    + {`import { + type Purchase, + fetchProducts, + requestPurchase, + getAvailablePurchases, + finishTransaction, + verifyPurchaseWithProvider, +} from 'expo-iap'; +// React Native uses the same top-level API names from 'react-native-iap'. + +const products = await fetchProducts({ + skus: ['dev.hyo.martie.10bulbs'], + type: 'in-app', +}); +const [product] = products ?? []; +if (!product) throw new Error('Amazon product not found'); + +await requestPurchase({ + request: { google: { skus: [product.id] } }, + type: 'in-app', +}); + +async function onPurchaseUpdated(purchase: Purchase) { + const result = await verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + receiptId: purchase.purchaseToken ?? purchase.id, + sandbox: true, + }, + }, + }); + + if (result.iapkit?.isValid) { + await finishTransaction({ purchase, isConsumable: true }); + await getAvailablePurchases(); + } +}`} +

    + In Expo and React Native, Android purchase request props are named{' '} + google because they share the Android request shape; the + Fire OS build selects the Amazon native module underneath. Flutter, + Kotlin Multiplatform, .NET MAUI, and Godot expose the same operations + as instance, suspend, async, or script calls. The article can + therefore show Fire OS once at the store layer, then point each + framework section back to the same OpenIAP lifecycle. +

    +
    + +
    + + Video Script + +

    + Keep each clip short, but make the action and the evidence visible. + This structure gives a joint article/video a clean sequence instead of + a raw list of recordings. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ClipOpen withClose on
    Overview + Show the Amazon Fire OS badge and explain that this is the + Amazon flavor of the shared OpenIAP example app. + + Land on the three menus: purchase flow, subscription flow, and + available purchases. +
    Purchase Flow + Show the verification selector, then the Amazon product rows. + + Show the purchase result or the tester/catalog requirement if + the store sheet cannot proceed. +
    Subscription Flow + Show monthly and annual subscription rows loaded through + OpenIAP. + + Show the Amazon-specific subscription management language. +
    Available PurchasesTap refresh or restore. + Show restored count, grouped purchase rows, or the empty state + with the Amazon Appstore account wording. +
    IAPKit VerificationShow the configured IAPKit option. + Explain that Amazon receipts go through the{' '} + iapkit.amazon payload before the app finishes the + transaction. +
    +
    + +
    + + Build and Run + +

    + Source: packages/google/Example/ +

    + {`cd packages/google +./gradlew :Example:assembleAmazonDebug +./gradlew :Example:installAmazonDebug + +adb shell monkey -p dev.hyo.martie -c android.intent.category.LAUNCHER 1`} +

    + Live Amazon purchases require Amazon Appstore tester setup, matching + product IDs, and a Fire OS device signed in with an eligible Amazon + account. The debug build is still useful for adapter wiring, UI + walkthroughs, and video capture. +

    +
    + +
    + + IAPKit Key + +

    + IAPKit is the managed verification backend for this example. Configure + the project key from{' '} + + kit.openiap.dev + {' '} + before recording the verification clip. +

    + {`# packages/google/local.properties +iapkit.api.key=openiap-kit_`} +
    + +
    + + Recording Script + +

    + Record each feature separately so the article can embed short clips + beside the matching explanation. +

    + {`mkdir -p packages/docs/public/examples/amazon/videos + +adb shell screenrecord /sdcard/fireos-overview.mp4 +adb pull /sdcard/fireos-overview.mp4 \\ + packages/docs/public/examples/amazon/videos/fireos-overview.mp4 + +adb shell screenrecord /sdcard/fireos-inapp.mp4 +adb pull /sdcard/fireos-inapp.mp4 \\ + packages/docs/public/examples/amazon/videos/fireos-inapp.mp4 + +adb shell screenrecord /sdcard/fireos-subscription.mp4 +adb pull /sdcard/fireos-subscription.mp4 \\ + packages/docs/public/examples/amazon/videos/fireos-subscription.mp4 + +adb shell screenrecord /sdcard/fireos-available-purchases.mp4 +adb pull /sdcard/fireos-available-purchases.mp4 \\ + packages/docs/public/examples/amazon/videos/fireos-available-purchases.mp4 + +adb shell screenrecord /sdcard/fireos-verification.mp4 +adb pull /sdcard/fireos-verification.mp4 \\ + packages/docs/public/examples/amazon/videos/fireos-verification.mp4`} +
    + +
    + + Source + +

    + + View Fire OS Example on GitHub + +

    +
    +
    + ); +} + +export default FireOSExample; diff --git a/packages/docs/src/pages/docs/examples/horizon.tsx b/packages/docs/src/pages/docs/examples/horizon.tsx new file mode 100644 index 00000000..c83d6a3f --- /dev/null +++ b/packages/docs/src/pages/docs/examples/horizon.tsx @@ -0,0 +1,351 @@ +import StoreExampleTemplate, { + type StoreExampleConfig, +} from './StoreExampleTemplate'; + +const HORIZON_VIDEO_BASE = '/examples/horizon/videos'; +const HORIZON_POSTER = '/examples/horizon/home.webp'; +const HORIZON_VIDEO_VERSION = 'v=20260527-subscription-purchase'; + +function horizonVideo(fileName: string) { + return `${HORIZON_VIDEO_BASE}/${fileName}?${HORIZON_VIDEO_VERSION}`; +} + +export const HORIZON_CONFIG: StoreExampleConfig = { + title: 'Horizon OS Example', + seo: { + title: 'Horizon OS Example', + description: + 'OpenIAP Meta Horizon Billing example walkthrough for Quest purchases, subscriptions, restore flows, and entitlement verification.', + path: '/docs/example/horizon', + keywords: + 'OpenIAP Horizon example, Meta Horizon Billing example, Quest IAP video, Horizon entitlement verification', + }, + storeName: 'Meta Horizon Billing', + sourcePath: 'packages/google/Example/', + sourceHref: + 'https://github.com/hyodotdev/openiap/tree/main/packages/google/Example', + intro: ( + <> + The Horizon OS example is the shared Android Kotlin/Compose app compiled + with the Horizon flavor. It links Meta Horizon Billing, uses Horizon + Developer Hub product IDs, maps Horizon purchases into the OpenIAP Android + purchase model, and keeps entitlement verification tied to Meta's + server-side verification path. + + ), + goal: ( + <> + prove the app is using Meta Horizon Billing end to end: Horizon catalog + lookup, Quest purchase launch, purchase update handling, entitlement + verification, restore, and acknowledgement or consumption. + + ), + overview: ( + <> + This page uses the same walkthrough structure as Fire OS, but the + recordings are captured from a Quest 3 through Meta Quest Casting so the + viewer sees the Horizon OS panel running on-device. + + ), + proofPoints: [ + { + area: 'Adapter selection', + proof: ( + <> + The Horizon flavor loads the Meta Horizon Billing compatibility module + instead of Google Play Billing or Amazon Appstore IAP. + + ), + where: 'Home badge, build flavor, and Horizon-specific setup.', + }, + { + area: 'App identity', + proof: ( + <> + initConnection reads the Horizon app ID from the Android + manifest and prepares the billing client. + + ), + where: 'First load of each purchase screen.', + }, + { + area: 'Catalog', + proof: ( + <> + fetchProducts maps Horizon product details into OpenIAP + Android product and subscription product types. + + ), + where: 'Product and subscription rows.', + }, + { + area: 'Purchase token', + proof: ( + <> + Horizon purchase tokens and order IDs are carried through{' '} + PurchaseAndroid for restore, verification, and finish. + + ), + where: 'Purchase details and verification payload.', + }, + { + area: 'Fulfillment', + proof: ( + <> + finishTransaction acknowledges owned items and consumes + consumables through the Horizon Billing client after validation. + + ), + where: 'Purchase flow and unfinished transaction restore flow.', + }, + ], + productSkus: [ + 'dev.hyo.martie.10bulbs', + 'dev.hyo.martie.30bulbs', + 'dev.hyo.martie.certified', + ], + subscriptionSkus: ['dev.hyo.martie.premium', 'dev.hyo.martie.premium_year'], + purchaseRequestShape: ( + <> + an Android-compatible request shape, for example{' '} + google: {'{ skus }'}, with the Horizon flavor selecting the + Meta billing module underneath + + ), + subscriptionRequestShape: ( + <> + an Android-compatible subscription request with Horizon product IDs and + term/base-plan metadata + + ), + purchaseUpdateText: ( + <> + The example watches the latest PurchaseAndroid update emitted + from the Horizon billing listener. + + ), + finishText: ( + <> + Verify entitlement state, call finishTransaction, then + refresh getAvailablePurchases. + + ), + subscriptionManagementText: ( + <> + The clip should call out Horizon account requirements and the subscription + product setup used by Quest devices. + + ), + availablePurchasesText: ( + <> + This menu is the Horizon entitlement recovery story. It should show active + purchases returned from Horizon Billing through{' '} + getAvailablePurchases and restore actions. + + ), + verificationIntro: ( + <> + This clip shows where the app switches from local Horizon Billing state to + trusted entitlement validation. Horizon verification requires Meta app + credentials and should be performed on a backend or managed verification + service, not by shipping access tokens in the client. + + ), + verificationItems: [ + { + part: 'Meta credentials', + explanation: ( + <> + Horizon entitlement verification requires an app access token or user + access token. Keep that credential on a trusted backend. + + ), + }, + { + part: 'Horizon payload', + explanation: ( + <> + Use verifyPurchase with a horizon payload: + access token, SKU, and user ID. + + ), + }, + { + part: 'Unlock decision', + explanation: ( + <> + Grant access only after Meta entitlement verification succeeds for the + expected SKU and user. + + ), + }, + { + part: 'Finish order', + explanation: ( + <> + Acknowledge or consume only after verification and entitlement grant. + + ), + }, + ], + readinessTitle: 'Horizon OS Readiness', + readinessIntro: ( + <> + Before publishing the article or sharing the video, verify these items so + the demo reads as a real Meta Horizon Billing integration. + + ), + readinessItems: [ + { + item: 'Build flavor', + expected: ( + <> + Use :Example:assembleHorizonDebug so the app links Meta + Horizon Billing and loads the Horizon OpenIAP module. + + ), + }, + { + item: 'Horizon app ID', + expected: ( + <> + The Android manifest must include the Meta/Horizon app ID required by + the Horizon Billing SDK. + + ), + }, + { + item: 'Horizon products', + expected: ( + <> + Product IDs in Horizon Developer Hub must match the example SKU lists. + + ), + }, + { + item: 'Subscription levels', + expected: ( + <> + The example expects the monthly and yearly subscription SKUs to be + configured at the same Horizon level to avoid unintended automatic + upgrades. + + ), + }, + { + item: 'Quest tester', + expected: ( + <> + Record on a Quest/Horizon OS device signed in with an account that can + exercise the configured test catalog. + + ), + }, + { + item: 'Verification backend', + expected: ( + <> + Prepare a backend or managed service that can hold Meta credentials + and call entitlement verification safely. + + ), + }, + ], + frameworkIntro: ( + <> + The Horizon video should prove the Quest billing layer first. App teams + can then move the same lifecycle into Expo, React Native, Flutter, Kotlin + Multiplatform, .NET MAUI, and Godot where Horizon support is enabled. + + ), + frameworkVerificationApi: { + label: 'verifyPurchase', + to: '/docs/features/validation#verify-purchase', + }, + frameworkNote: ( + <> + For Horizon, use the Horizon verification payload on a trusted backend. + Keep Meta access tokens out of client apps. + + ), + frameworkSnippet: `import { + type Purchase, + fetchProducts, + requestPurchase, + getAvailablePurchases, + finishTransaction, +} from 'expo-iap'; + +const products = await fetchProducts({ + skus: ['dev.hyo.martie.10bulbs'], + type: 'in-app', +}); +const [product] = products ?? []; +if (!product) throw new Error('Horizon product not found'); + +await requestPurchase({ + request: { google: { skus: [product.id] } }, + type: 'in-app', +}); + +async function onPurchaseUpdated(purchase: Purchase) { + // Call your backend; do not ship Meta access tokens in the app. + const verified = await verifyHorizonPurchaseOnBackend({ + sku: purchase.productId, + purchaseToken: purchase.purchaseToken ?? purchase.id, + }); + + if (verified.success) { + await finishTransaction({ purchase, isConsumable: true }); + await getAvailablePurchases(); + } +}`, + buildCommand: `cd packages/google +./gradlew :Example:assembleHorizonDebug +./gradlew :Example:installHorizonDebug + +adb shell monkey -p dev.hyo.martie -c android.intent.category.LAUNCHER 1`, + videos: { + overview: { + title: 'Overview', + description: + 'Quest launch, Horizon Billing context, and feature navigation.', + src: horizonVideo('horizon-overview.mp4'), + poster: HORIZON_POSTER, + }, + purchase: { + title: 'Purchase Flow', + description: + 'Product screen, Buy action, Horizon confirmation, and post-purchase transaction state.', + src: horizonVideo('horizon-inapp.mp4'), + poster: HORIZON_POSTER, + }, + subscription: { + title: 'Subscription Flow', + description: + 'Subscription screen, upgrade action, Horizon confirmation, and active subscription state.', + src: horizonVideo('horizon-subscription.mp4'), + poster: HORIZON_POSTER, + }, + available: { + title: 'Available Purchases', + description: + 'Restore and entitlement recovery surface backed by getAvailablePurchases.', + src: horizonVideo('horizon-available-purchases.mp4'), + poster: HORIZON_POSTER, + }, + verification: { + title: 'Purchase Verification', + description: + 'Verification provider selector and the handoff to trusted Meta entitlement verification.', + src: horizonVideo('horizon-verification.mp4'), + poster: HORIZON_POSTER, + }, + }, +}; + +function HorizonExample() { + return ; +} + +export default HorizonExample; diff --git a/packages/docs/src/pages/docs/examples/index.tsx b/packages/docs/src/pages/docs/examples/index.tsx new file mode 100644 index 00000000..6a986909 --- /dev/null +++ b/packages/docs/src/pages/docs/examples/index.tsx @@ -0,0 +1,651 @@ +import StoreExampleTemplate, { + type StoreExampleConfig, +} from './StoreExampleTemplate'; +import { IOS_CONFIG } from './ios'; +import { ANDROID_CONFIG } from './android'; +import { HORIZON_CONFIG } from './horizon'; +import { IAPKIT_URL, trackIapKitClick } from '../../../lib/config'; + +const FIREOS_VIDEO_BASE = '/examples/amazon/videos'; +const FIREOS_POSTER = '/examples/amazon/home.webp'; +const FIREOS_VIDEO_VERSION = 'v=20260526-corrected'; + +function fireOsVideo(fileName: string) { + return `${FIREOS_VIDEO_BASE}/${fileName}?${FIREOS_VIDEO_VERSION}`; +} + +const PRODUCT_SKUS = [ + 'dev.hyo.martie.10bulbs', + 'dev.hyo.martie.30bulbs', + 'dev.hyo.martie.certified', +]; + +const SUBSCRIPTION_SKUS = [ + 'dev.hyo.martie.premium', + 'dev.hyo.martie.premium_year', +]; + +const FIREOS_CONFIG: StoreExampleConfig = { + title: 'Fire OS Example', + seo: { + title: 'Fire OS Example', + description: + 'OpenIAP Amazon Appstore IAP example walkthrough for Fire OS purchases, subscriptions, restore flows, and IAPKit verification.', + path: '/docs/example', + keywords: + 'OpenIAP Fire OS example, Amazon Appstore IAP, Fire tablet IAP, Amazon RVS, IAPKit verification', + }, + storeName: 'Amazon Appstore IAP', + sourcePath: 'packages/google/Example/', + sourceHref: + 'https://github.com/hyodotdev/openiap/tree/main/packages/google/Example', + intro: ( + <> + The Fire OS recording uses the shared Android Kotlin/Compose example app + compiled with the amazon flavor. It links the Amazon Appstore + SDK, uses Amazon catalog IDs, maps receipt IDs into the OpenIAP Android + purchase model, and verifies purchases through IAPKit when managed + verification is enabled. + + ), + goal: ( + <> + prove the app is using Amazon Appstore IAP end to end: product catalog + lookup, one-SKU purchase launch, purchase update handling, receipt ID + verification, restore, and fulfillment. + + ), + overview: ( + <> + This tab shows the Fire OS version of the same example walkthrough. The + written flow stays shared across targets; only the store surface, + readiness checklist, and video recordings change. + + ), + proofPoints: [ + { + area: 'Adapter selection', + proof: ( + <> + The amazon flavor loads the Amazon module instead of Play + Billing or Horizon Billing. + + ), + where: 'Home badge, build flavor, and Fire OS setup.', + }, + { + area: 'User context', + proof: ( + <> + initConnection registers Amazon IAP callbacks and + requests Amazon user data for receipt verification. + + ), + where: 'First load of each purchase screen.', + }, + { + area: 'Catalog', + proof: ( + <> + fetchProducts maps Amazon consumables, entitlements, and + subscriptions into OpenIAP product types. + + ), + where: 'Product and subscription rows.', + }, + { + area: 'Receipt identity', + proof: ( + <> + Amazon receiptId is exposed through the Android purchase + shape as the purchase token used by verification and fulfillment. + + ), + where: 'Purchase details and verification payload.', + }, + { + area: 'Fulfillment', + proof: ( + <> + finishTransaction calls Amazon fulfillment with the + receipt ID after validation. + + ), + where: 'Purchase flow and unfinished transaction restore flow.', + }, + ], + productSkus: PRODUCT_SKUS, + subscriptionSkus: SUBSCRIPTION_SKUS, + purchaseRequestShape: ( + <> + an Android-compatible request shape. The Fire OS build selects the Amazon + module underneath and sends one SKU to Amazon Appstore IAP. + + ), + subscriptionRequestShape: ( + <> + an Android-compatible subscription request with the selected Amazon + subscription SKU + + ), + purchaseUpdateText: ( + <> + The screen watches currentPurchase and the latest{' '} + PurchaseAndroid update emitted by the Amazon listener. + + ), + finishText: ( + <> + Verify with IAPKit or your backend, call finishTransaction, + then refresh getAvailablePurchases. + + ), + subscriptionManagementText: ( + <> + The Fire OS clip should call out Amazon Appstore account requirements and + Amazon subscription management language. + + ), + availablePurchasesText: ( + <> + This menu is the Fire OS entitlement recovery story. On entry, refresh, + and restore, the screen calls getAvailablePurchases. The + Amazon adapter backs that with purchase updates from the signed-in Amazon + Appstore account. + + ), + verificationIntro: ( + <> + This clip shows where the app switches from local Amazon purchase state to + managed validation through IAPKit and Amazon Receipt Verification Service. + Add the project key from{' '} + + kit.openiap.dev + {' '} + before recording managed verification. + + ), + verificationItems: [ + { + part: 'IAPKit key', + explanation: ( + <> + Configure iapkit.api.key before recording managed + verification. Do not ship long-lived backend credentials in the app. + + ), + }, + { + part: 'Amazon payload', + explanation: ( + <> + Use verifyPurchaseWithProvider with an{' '} + iapkit.amazon payload containing the Amazon receipt ID. + + ), + }, + { + part: 'Unlock decision', + explanation: ( + <> + Grant access only after verification succeeds; do not trust a + client-only premium flag or a button tap. + + ), + }, + { + part: 'Finish order', + explanation: ( + <> + Finish after verification and entitlement grant because finishing + tells Amazon that the purchase was fulfilled. + + ), + }, + ], + readinessTitle: 'Fire OS Readiness', + readinessIntro: ( + <> + Before publishing the article or sharing the video, verify these items so + the recording reads as an Amazon Appstore integration rather than a + generic Android screen recording. + + ), + readinessItems: [ + { + item: 'Build flavor', + expected: ( + <> + Use :Example:assembleAmazonDebug so the app links the + Amazon Appstore SDK and loads the Amazon OpenIAP module. + + ), + }, + { + item: 'Catalog IDs', + expected: ( + <> + Amazon catalog entries should match the example in-app and + subscription SKU lists. + + ), + }, + { + item: 'Tester account', + expected: ( + <> + The Fire OS tablet must be signed in with an Amazon account that can + exercise the configured Appstore test catalog. + + ), + }, + { + item: 'Verification key', + expected: ( + <> + Set iapkit.api.key in{' '} + packages/google/local.properties before recording the + IAPKit clip. + + ), + }, + { + item: 'Sandbox receipts', + expected: ( + <> + Use the IAPKit Amazon payload with sandbox: true for + tester receipts so RVS validation uses the correct environment. + + ), + }, + ], + frameworkIntro: ( + <> + The Fire OS video proves the Amazon adapter at the native store layer + first. App teams can move the same lifecycle into Expo, React Native, + Flutter, Kotlin Multiplatform, .NET MAUI, and Godot without inventing new + operation names. + + ), + frameworkNote: ( + <> + For Amazon, pass the IAPKit Amazon payload with the receipt ID. Expo and + React Native reuse the Android purchase request shape while the Fire OS + build selects the Amazon native module underneath. + + ), + frameworkVerificationApi: { + label: 'verifyPurchaseWithProvider', + to: '/docs/features/validation#verify-purchase-with-provider', + }, + frameworkSnippet: `import { + type Purchase, + fetchProducts, + requestPurchase, + getAvailablePurchases, + finishTransaction, + verifyPurchaseWithProvider, +} from 'expo-iap'; + +const products = await fetchProducts({ + skus: ['dev.hyo.martie.10bulbs'], + type: 'in-app', +}); +const [product] = products ?? []; +if (!product) throw new Error('Amazon product not found'); + +await requestPurchase({ + request: { google: { skus: [product.id] } }, + type: 'in-app', +}); + +async function onPurchaseUpdated(purchase: Purchase) { + const result = await verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + receiptId: purchase.purchaseToken ?? purchase.id, + sandbox: true, + }, + }, + }); + + if (result.iapkit?.isValid) { + await finishTransaction({ purchase, isConsumable: true }); + await getAvailablePurchases(); + } +}`, + buildCommand: `cd packages/google +./gradlew :Example:assembleAmazonDebug +./gradlew :Example:installAmazonDebug + +adb shell monkey -p dev.hyo.martie -c android.intent.category.LAUNCHER 1`, + videos: { + overview: { + title: 'Overview', + description: + 'App launch, Fire OS store context, and the feature menu used throughout the walkthrough.', + src: fireOsVideo('fireos-overview.mp4'), + poster: FIREOS_POSTER, + }, + purchase: { + title: 'Purchase Flow', + description: + 'Consumable product screen, product fetch, purchase action, and verification result area.', + src: fireOsVideo('fireos-inapp.mp4'), + poster: FIREOS_POSTER, + }, + subscription: { + title: 'Subscription Flow', + description: + 'Subscription product screen, offer list, subscription action, and Amazon tester requirements.', + src: fireOsVideo('fireos-subscription.mp4'), + poster: FIREOS_POSTER, + }, + available: { + title: 'Available Purchases', + description: + 'Restore and available-purchase screen for entitlement recovery and receipt inspection.', + src: fireOsVideo('fireos-available-purchases.mp4'), + poster: FIREOS_POSTER, + }, + verification: { + title: 'Purchase Verification', + description: + 'IAPKit verification wiring, local app state, and where Amazon RVS-backed results appear.', + src: fireOsVideo('fireos-verification.mp4'), + poster: FIREOS_POSTER, + }, + }, +}; + +const VIDEO_TARGETS = [ + { id: 'apple', label: 'Apple', config: IOS_CONFIG }, + { id: 'google', label: 'Google', config: ANDROID_CONFIG }, + { id: 'horizon', label: 'Horizon OS', config: HORIZON_CONFIG }, + { id: 'fireos', label: 'Fire OS', config: FIREOS_CONFIG }, +]; + +function variantsFor(key: keyof StoreExampleConfig['videos']) { + return VIDEO_TARGETS.map((target) => { + const video = target.config.videos[key]; + + return { + id: target.id, + label: target.label, + title: video.title, + description: video.description, + src: video.src, + poster: video.poster, + }; + }); +} + +const EXAMPLE_CONFIG: StoreExampleConfig = { + ...FIREOS_CONFIG, + title: 'Example', + seo: { + title: 'Example', + description: + 'Run the OpenIAP example app and compare store-specific recordings for Apple, Google, Horizon OS, and Fire OS.', + path: '/docs/example', + keywords: + 'OpenIAP example app, IAP example video, App Store IAP, Google Play Billing, Meta Horizon Billing, Amazon Appstore IAP', + }, + storeName: 'store adapter', + intro: ( + <> + The OpenIAP example uses the same product, subscription, restore, and + verification screens across store targets. The written walkthrough stays + shared; each video card lets you switch between Apple, Google, Horizon OS, + and Fire OS recordings for the same action. + + ), + goal: ( + <> + prove the store adapter works end to end: catalog lookup, purchase launch, + purchase update handling, verification, restore, and final transaction + fulfillment. + + ), + overview: ( + <> + Each clip is recorded from the store-specific example build, but the + sequence is intentionally the same so the article can compare store + behavior without duplicating the whole document. + + ), + overviewImage: { + src: FIREOS_POSTER, + alt: 'OpenIAP example app home screen', + }, + proofPoints: [ + { + area: 'Adapter selection', + proof: ( + <> + The selected build links the matching store adapter: StoreKit 2, + Google Play Billing, Meta Horizon Billing, or Amazon Appstore IAP. + + ), + where: 'Home badge, build flavor, and setup notes.', + }, + { + area: 'Connection', + proof: ( + <> + initConnection prepares the store listener before product + or transaction calls run. + + ), + where: 'First load of each purchase screen.', + }, + { + area: 'Catalog', + proof: ( + <> + fetchProducts maps store catalog data into OpenIAP + product and subscription types. + + ), + where: 'Product and subscription rows.', + }, + { + area: 'Purchase identity', + proof: ( + <> + Store transaction IDs, purchase tokens, receipt IDs, or signed + transaction data are carried through the OpenIAP purchase shape for + verification and finish. + + ), + where: 'Purchase details and verification payload.', + }, + { + area: 'Fulfillment', + proof: ( + <> + finishTransaction runs only after validation and + entitlement grant. + + ), + where: 'Purchase flow and unfinished transaction restore flow.', + }, + ], + purchaseRequestShape: <>the store-compatible request shape for that target, + subscriptionRequestShape: ( + <>the store-compatible subscription request shape for that target + ), + purchaseUpdateText: ( + <> + The screen watches the latest OpenIAP purchase update emitted by the store + listener. + + ), + finishText: ( + <> + Verify with IAPKit or your backend, call finishTransaction, + then refresh getAvailablePurchases. + + ), + subscriptionManagementText: ( + <> + The selected video shows the target store's subscription confirmation, + management, or tester-account requirements. + + ), + availablePurchasesText: ( + <> + This menu is the entitlement recovery story. On entry, refresh, and + restore, the screen calls getAvailablePurchases so the app + can rebuild local access from the active store account. + + ), + verificationIntro: ( + <> + This clip shows where the app switches from local store state to trusted + validation. IAPKit provider verification uses{' '} + verifyPurchaseWithProvider, while custom backends can call + the same store-specific verification services from a trusted environment. + + ), + verificationItems: [ + { + part: 'Provider payload', + explanation: ( + <> + Send the matching IAPKit payload for Apple, Google, Amazon, or Horizon + instead of shipping provider secrets in the app. + + ), + }, + { + part: 'Store evidence', + explanation: ( + <> + Include the signed transaction, purchase token, receipt ID, or + entitlement data required by the selected store. + + ), + }, + { + part: 'Unlock decision', + explanation: ( + <> + Grant access only after the verified response matches the expected + product and account state. + + ), + }, + { + part: 'Finish order', + explanation: ( + <>Finish the transaction after verification and entitlement grant. + ), + }, + ], + readinessTitle: 'Recording Readiness', + readinessIntro: ( + <> + Before publishing the article or sharing the video, verify the target + store setup so each tab reads as a real integration rather than a generic + screen recording. + + ), + readinessItems: [ + { + item: 'Build target', + expected: ( + <> + Install the matching example build for Apple, Google, Horizon OS, or + Fire OS before recording that tab. + + ), + }, + { + item: 'Catalog IDs', + expected: ( + <> + Store catalog entries should match the example in-app and subscription + SKU lists. + + ), + }, + { + item: 'Tester account', + expected: ( + <> + Use a sandbox, license tester, Quest tester, or Amazon tester account + that can open the purchase sheet. + + ), + }, + { + item: 'Verification key', + expected: ( + <> + Configure the IAPKit key or backend endpoint before recording the + verification clip. + + ), + }, + ], + frameworkIntro: ( + <> + The native examples prove the store layer first. App teams can move the + same lifecycle into Expo, React Native, Flutter, Kotlin Multiplatform, + .NET MAUI, and Godot without changing the OpenIAP operation names. + + ), + frameworkNote: ( + <> + Use the provider payload for the selected store. The request shape changes + by language and platform, but the lifecycle remains fetch, request, + verify, finish, and refresh. + + ), + videos: { + overview: { + title: 'Overview', + description: + 'Switch tabs to compare the same example home flow across store targets.', + variants: variantsFor('overview'), + }, + purchase: { + title: 'Purchase Flow', + description: + 'Switch tabs to compare the same in-app purchase action across store targets.', + variants: variantsFor('purchase'), + }, + subscription: { + title: 'Subscription Flow', + description: + 'Switch tabs to compare the same subscription action across store targets.', + variants: variantsFor('subscription'), + }, + available: { + title: 'Available Purchases', + description: + 'Switch tabs to compare restore and entitlement recovery across store targets.', + variants: variantsFor('available'), + }, + verification: { + title: 'Purchase Verification', + description: + 'Switch tabs to compare the verification handoff across store targets.', + variants: variantsFor('verification'), + }, + }, +}; + +function ExampleIndex() { + return ; +} + +export default ExampleIndex; diff --git a/packages/docs/src/pages/docs/examples/ios.tsx b/packages/docs/src/pages/docs/examples/ios.tsx new file mode 100644 index 00000000..d411d9eb --- /dev/null +++ b/packages/docs/src/pages/docs/examples/ios.tsx @@ -0,0 +1,336 @@ +import StoreExampleTemplate, { + type StoreExampleConfig, +} from './StoreExampleTemplate'; + +const APPLE_ASSET_BASE = '/examples/apple'; +const APPLE_VIDEO_BASE = `${APPLE_ASSET_BASE}/videos`; +const APPLE_VIDEO_VERSION = 'v=20260528-storekit-ios-edited'; + +function appleVideo(fileName: string) { + return `${APPLE_VIDEO_BASE}/${fileName}?${APPLE_VIDEO_VERSION}`; +} + +export const IOS_CONFIG: StoreExampleConfig = { + title: 'iOS Example', + seo: { + title: 'iOS Example', + description: + 'OpenIAP StoreKit 2 example walkthrough for iOS purchases, subscriptions, restore flows, and IAPKit verification.', + path: '/docs/example/ios', + keywords: + 'OpenIAP iOS example, StoreKit 2 example, iOS IAP video, App Store purchase verification', + }, + storeName: 'App Store / StoreKit 2', + sourcePath: 'packages/apple/Example/', + sourceHref: + 'https://github.com/hyodotdev/openiap/tree/main/packages/apple/Example', + intro: ( + <> + The iOS example is the SwiftUI app backed by the OpenIAP Apple package. It + uses StoreKit 2, App Store Connect product IDs, StoreKit transactions, and + the same OpenIAP lifecycle used by the framework SDKs. + + ), + goal: ( + <> + prove the app is using StoreKit 2 end to end: App Store Connect catalog + lookup, purchase launch, transaction update handling, signed transaction + verification, restore, and final transaction finish. + + ), + overview: ( + <> + Each clip is recorded from the native SwiftUI example running through + iPhone Mirroring. The flow uses the same action-by-action structure as the + other stores, while the StoreKit 2 sandbox sheet shows the Apple-specific + confirmation step. + + ), + overviewImage: { + src: `${APPLE_ASSET_BASE}/home.webp`, + alt: 'OpenIAP iOS example home screen', + }, + proofPoints: [ + { + area: 'Adapter selection', + proof: ( + <> + The app links the Apple package and calls the StoreKit 2 + implementation instead of an Android billing adapter. + + ), + where: 'Home screen and SwiftUI example source.', + }, + { + area: 'User context', + proof: ( + <> + initConnection prepares StoreKit listeners before product + and transaction calls run. + + ), + where: 'First load of each purchase screen.', + }, + { + area: 'Catalog', + proof: ( + <> + fetchProducts maps App Store Connect products and + subscription products into OpenIAP product types. + + ), + where: 'Product and subscription rows.', + }, + { + area: 'Transaction identity', + proof: ( + <> + StoreKit signed transaction data is carried through the OpenIAP + purchase shape for server or IAPKit verification. + + ), + where: 'Purchase details and verification payload.', + }, + { + area: 'Finish', + proof: ( + <> + finishTransaction completes the StoreKit transaction + after validation and entitlement grant. + + ), + where: 'Purchase flow and restore flow.', + }, + ], + productSkus: [ + 'dev.hyo.martie.10bulbs', + 'dev.hyo.martie.30bulbs', + 'dev.hyo.martie.certified', + ], + subscriptionSkus: ['dev.hyo.martie.premium', 'dev.hyo.martie.premium_year'], + purchaseRequestShape: ( + <> + an Apple purchase request, for example apple: {'{ sku }'} + + ), + subscriptionRequestShape: ( + <> + an Apple subscription request, for example apple: {'{ sku }'} + + ), + purchaseUpdateText: ( + <> + The example listens for StoreKit transaction updates and maps them into + OpenIAP PurchaseIOS values. + + ), + finishText: ( + <> + Verify, grant the entitlement, call finishTransaction, then + refresh getAvailablePurchases. + + ), + subscriptionManagementText: ( + <> + The clip calls out StoreKit subscription products, sandbox account + confirmation, and the same purchase update path used by the in-app flow. + + ), + availablePurchasesText: ( + <> + This menu is the iOS entitlement recovery story. It should show + Transaction.currentEntitlements-backed restore behavior through{' '} + getAvailablePurchases and restorePurchases. + + ), + verificationIntro: ( + <> + This clip shows where the app switches from local StoreKit state to + server-side validation. For IAPKit, iOS sends the StoreKit signed + transaction JWS through the Apple payload. + + ), + verificationItems: [ + { + part: 'IAPKit key', + explanation: ( + <> + Configure the project key before recording managed verification. The + app should not expose long-lived backend credentials. + + ), + }, + { + part: 'Apple payload', + explanation: ( + <> + Use verifyPurchaseWithProvider with an{' '} + iapkit.apple payload containing the StoreKit JWS. + + ), + }, + { + part: 'Unlock decision', + explanation: ( + <> + Grant access only after the verified transaction matches the expected + product and account state. + + ), + }, + { + part: 'Finish order', + explanation: ( + <> + Finish the StoreKit transaction after verification and entitlement + grant, not before. + + ), + }, + ], + readinessTitle: 'iOS Readiness', + readinessIntro: ( + <> + Before publishing the article or sharing the video, verify these items so + the demo reads as a real App Store integration. + + ), + readinessItems: [ + { + item: 'App Store Connect products', + expected: ( + <> + The in-app and subscription product IDs must exist and be available + for the app bundle used by the example. + + ), + }, + { + item: 'Sandbox tester', + expected: ( + <> + The device should be signed in with an App Store sandbox tester or a + StoreKit testing setup appropriate for the recording. + + ), + }, + { + item: 'Bundle ID', + expected: ( + <> + The Xcode target bundle identifier must match the App Store Connect + app that owns the products. + + ), + }, + { + item: 'Verification key', + expected: ( + <>Configure the IAPKit key before recording the verification clip. + ), + }, + { + item: 'Restore state', + expected: ( + <> + Prepare at least one owned non-consumable or active subscription so + the restore clip shows meaningful entitlement recovery. + + ), + }, + ], + frameworkIntro: ( + <> + The iOS video should prove the StoreKit 2 layer first. App teams can then + move the same lifecycle into Expo, React Native, Flutter, Kotlin + Multiplatform, .NET MAUI, and Godot. + + ), + frameworkVerificationApi: { + label: 'verifyPurchaseWithProvider', + to: '/docs/features/validation#verify-purchase-with-provider', + }, + frameworkNote: ( + <> + For iOS, pass the IAPKit Apple payload with the StoreKit signed + transaction JWS. + + ), + frameworkSnippet: `import { + type Purchase, + fetchProducts, + requestPurchase, + getAvailablePurchases, + finishTransaction, + verifyPurchaseWithProvider, +} from 'expo-iap'; + +const products = await fetchProducts({ + skus: ['dev.hyo.martie.10bulbs'], + type: 'in-app', +}); +const [product] = products ?? []; +if (!product) throw new Error('App Store product not found'); + +await requestPurchase({ + request: { apple: { sku: product.id } }, + type: 'in-app', +}); + +async function onPurchaseUpdated(purchase: Purchase) { + const jws = purchase.purchaseToken ?? purchase.id; + const result = await verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { apple: { jws } }, + }); + + if (result.iapkit?.isValid) { + await finishTransaction({ purchase, isConsumable: true }); + await getAvailablePurchases(); + } +}`, + buildCommand: `cd packages/apple/Example +open Martie.xcodeproj`, + videos: { + overview: { + title: 'Overview', + description: + 'SwiftUI example home screen showing the iOS StoreKit 2 target and shared feature menus.', + poster: `${APPLE_ASSET_BASE}/home.webp`, + }, + purchase: { + title: 'Purchase Flow', + description: + 'Product screen, StoreKit catalog state, and the sandbox purchase sheet for an in-app product.', + src: appleVideo('apple-inapp.mp4'), + poster: `${APPLE_ASSET_BASE}/purchase-flow.webp`, + }, + subscription: { + title: 'Subscription Flow', + description: + 'Subscription product screen and StoreKit sandbox confirmation for the monthly premium plan.', + src: appleVideo('apple-subscription.mp4'), + poster: `${APPLE_ASSET_BASE}/subscription-flow-upgrade.webp`, + }, + available: { + title: 'Available Purchases', + description: + 'Current entitlement recovery and purchase history backed by StoreKit transaction state.', + src: appleVideo('apple-available-purchases.mp4'), + poster: `${APPLE_ASSET_BASE}/available-purchases.webp`, + }, + verification: { + title: 'Purchase Verification', + description: + 'Verification selector and product context before sending the StoreKit JWS to IAPKit.', + src: appleVideo('apple-verification.mp4'), + poster: `${APPLE_ASSET_BASE}/purchase-flow.webp`, + }, + }, +}; + +function IosExample() { + return ; +} + +export default IosExample; diff --git a/packages/docs/src/pages/docs/features/alternative-marketplace/onside.tsx b/packages/docs/src/pages/docs/features/alternative-marketplace/onside.tsx index 43ef3d78..38594405 100644 --- a/packages/docs/src/pages/docs/features/alternative-marketplace/onside.tsx +++ b/packages/docs/src/pages/docs/features/alternative-marketplace/onside.tsx @@ -161,7 +161,7 @@ function Store() { const { products, fetchProducts, requestPurchase } = useIAP({ onPurchaseSuccess: async (purchase) => { // Works with both Apple and Onside purchases - await finishTransaction(purchase, false); + await finishTransaction({ purchase, isConsumable: false }); }, onPurchaseError: (error) => { console.error(error.message); diff --git a/packages/docs/src/pages/docs/features/offer-code-redemption.tsx b/packages/docs/src/pages/docs/features/offer-code-redemption.tsx index 31025e88..5d1fda21 100644 --- a/packages/docs/src/pages/docs/features/offer-code-redemption.tsx +++ b/packages/docs/src/pages/docs/features/offer-code-redemption.tsx @@ -230,7 +230,7 @@ function RedeemCodeButton() { // Verify and finish the transaction const isValid = await verifyPurchaseOnServer(purchase); if (isValid) { - await finishTransaction(purchase, false); + await finishTransaction({ purchase, isConsumable: false }); console.log('Redemption completed successfully'); } }); diff --git a/packages/docs/src/pages/docs/features/purchase.tsx b/packages/docs/src/pages/docs/features/purchase.tsx index affdf176..3822606f 100644 --- a/packages/docs/src/pages/docs/features/purchase.tsx +++ b/packages/docs/src/pages/docs/features/purchase.tsx @@ -874,8 +874,7 @@ Future verifyOnServer(ProductPurchase purchase) async { Verify Purchase with IAPKit

    - Don't want to implement App Store / Google Play verification - yourself?{' '} + Don't want to implement store receipt verification yourself?{' '} verifyOnServer(ProductPurchase purchase) async { > IAPKit {' '} - is a hosted purchase verification service that validates App Store and - Google Play purchases for you. Use{' '} + is a hosted purchase verification service that validates App Store, + Google Play, Amazon Appstore, and Meta Horizon purchases for you. Use{' '} verifyPurchaseWithProvider with the{' '} 'iapkit' provider and pass the - platform-specific token (iOS JWS or Android purchase token) — no - store-verification code required. If your own backend serves protected - paid resources, have that backend authenticate the user and query - IAPKit before serving them; direct app-to-IAPKit calls are fine for - in-app or local feature unlocks, but they cannot authorize backend + platform-specific token or receipt payload. Fire OS and Vega OS use + iapkit.amazon with the Amazon receipt id, and no + app-owned Amazon RVS server is required. If your own backend serves + protected paid resources, have that backend authenticate the user and + query IAPKit before serving them; direct app-to-IAPKit calls are fine + for in-app or local feature unlocks, but they cannot authorize backend resources by themselves.

    @@ -922,15 +922,35 @@ import { verifyPurchaseWithProvider, type Purchase } from 'expo-iap'; // Same API in react-native-iap: // import { verifyPurchaseWithProvider, type Purchase } from 'react-native-iap'; +const iapkitPayloadFor = async (purchase: Purchase) => { + const token = purchase.purchaseToken ?? ''; + const runtimeOS = Platform.OS as string; + const isFireOSBuild = process.env.EXPO_PUBLIC_STORE === 'amazon'; + const isAmazonRuntime = runtimeOS === 'kepler' || isFireOSBuild; + + if (Platform.OS === 'ios') { + return { apple: { jws: token } }; + } + + if (isAmazonRuntime) { + return { + amazon: { + receiptId: token, + sandbox: __DEV__, + }, + }; + } + + return { google: { purchaseToken: token } }; +}; + const verifyWithIapkit = async (purchase: Purchase) => { const result = await verifyPurchaseWithProvider({ provider: 'iapkit', iapkit: { // apiKey is optional when configured via app config / Info.plist / AndroidManifest apiKey: process.env.EXPO_PUBLIC_IAPKIT_API_KEY, - ...(Platform.OS === 'ios' - ? { apple: { jws: purchase.purchaseToken ?? '' } } - : { google: { purchaseToken: purchase.purchaseToken ?? '' } }), + ...(await iapkitPayloadFor(purchase)), }, }); @@ -953,9 +973,7 @@ function PurchaseScreen() { provider: 'iapkit', iapkit: { apiKey: process.env.EXPO_PUBLIC_IAPKIT_API_KEY, - ...(Platform.OS === 'ios' - ? { apple: { jws: purchase.purchaseToken ?? '' } } - : { google: { purchaseToken: purchase.purchaseToken ?? '' } }), + ...(await iapkitPayloadFor(purchase)), }, }); if (!result.iapkit?.isValid) console.error('IAPKit verification failed'); @@ -1010,6 +1028,7 @@ suspend fun verifyWithIapkit(purchase: PurchaseAndroid): Boolean { google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = purchase.purchaseToken.orEmpty() ) + // Fire OS: replace google with amazon(userId, receiptId, sandbox). ) ) ) @@ -1041,6 +1060,7 @@ suspend fun verifyWithIapkit(purchase: PurchaseAndroid): Boolean { google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = purchase.purchaseToken.orEmpty() ) + // Fire OS builds use amazon(userId, receiptId, sandbox). ) ) ) @@ -1081,6 +1101,7 @@ Future verifyWithIapkit(ProductPurchase purchase) async { purchaseToken: purchase.purchaseToken ?? '', ) : null, + // Fire OS builds can pass amazon with userId, receiptId, and sandbox. ), ), ); diff --git a/packages/docs/src/pages/docs/features/runtime-integrations.tsx b/packages/docs/src/pages/docs/features/runtime-integrations.tsx new file mode 100644 index 00000000..4ba76190 --- /dev/null +++ b/packages/docs/src/pages/docs/features/runtime-integrations.tsx @@ -0,0 +1,84 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../components/AnchorLink'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +function RuntimeIntegrations() { + useScrollToHash(); + + return ( +
    + +

    Runtime Integrations

    + +

    + Runtime integrations are selected by the SDK after the app starts, + rather than by choosing a different Android flavor or changing purchase + code in screens. Onside and Vega OS live at this layer. +

    + +
    + + Supported Runtimes + + + + + + + + + + + + + + + + + + + + + +
    RuntimeScopeLibraries
    + + Onside + + iOS alternative marketplace runtime + expo-iap +
    + Vega OS + Amazon Vega JavaScript IAP runtime + react-native-iap, expo-iap +
    +
    + +
    + + Not Android Flavors + +

    + Android store flavors remain in the setup guides: Google Play is the + default, Horizon uses the horizon flavor, and Fire OS + uses the amazon flavor. Vega OS is separate because it + runs through Amazon's JavaScript IAP service in the{' '} + kepler runtime. +

    +

    + For Fire OS Android builds, use{' '} + Fire OS Setup. For Vega OS apps, + use the Vega OS Runtime{' '} + guide. +

    +
    +
    + ); +} + +export default RuntimeIntegrations; diff --git a/packages/docs/src/pages/docs/features/subscription/active-subscriptions.tsx b/packages/docs/src/pages/docs/features/subscription/active-subscriptions.tsx new file mode 100644 index 00000000..07781a9e --- /dev/null +++ b/packages/docs/src/pages/docs/features/subscription/active-subscriptions.tsx @@ -0,0 +1,324 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../../components/AnchorLink'; +import CodeBlock from '../../../../components/CodeBlock'; +import LanguageTabs from '../../../../components/LanguageTabs'; +import PlatformTabs from '../../../../components/PlatformTabs'; +import SEO from '../../../../components/SEO'; +import { useScrollToHash } from '../../../../hooks/useScrollToHash'; + +function ActiveSubscriptions() { + useScrollToHash(); + + return ( +
    + + +

    Active Subscriptions

    +

    + Active subscription checks answer the runtime question: which paid plan + should this user receive right now? OpenIAP keeps that workflow + consistent across Apple, Google Play, Meta Horizon, and Amazon Fire OS + by returning ActiveSubscription objects keyed by store + product identity instead of requiring app code to branch on each store's + subscription-group model. +

    + +
    + + Recommended Flow + +
      +
    1. + Configure subscription products in each store with stable product + IDs/SKUs for every plan you need to grant. +
    2. +
    3. + Fetch the product catalog with fetchProducts before + purchase so the SDK knows the store product type and available + offers. +
    4. +
    5. + Call requestPurchase with the selected subscription SKU + or offer. +
    6. +
    7. + On app launch, after purchase updates, and after restore, call{' '} + getActiveSubscriptions or{' '} + hasActiveSubscriptions with the subscription IDs your + feature accepts. +
    8. +
    9. + For server-backed access, verify with IAPKit or your store server + API and treat the server result as authoritative. +
    10. +
    + +

    + Do not build entitlement logic around a universal subscription group + field. Stores expose group information differently. The stable + cross-store fields are productId,{' '} + currentPlanId when available, and the store purchase + token/transaction ID for verification. +

    +
    + +
    + + API Usage + +

    + The same app code works for the native packages and framework SDKs. + Pass the product IDs/SKUs that unlock the feature; OpenIAP filters the + active store entitlements and returns the matching subscriptions. +

    + + + {{ + typescript: ( + {`import { getActiveSubscriptions, hasActiveSubscriptions } from 'expo-iap'; + +// React Native apps can import the same APIs from 'react-native-iap'. +const premiumIds = [ + 'dev.hyo.martie.premium', + 'dev.hyo.martie.premium_year', +]; + +const active = await getActiveSubscriptions(premiumIds); +const canUsePremium = active.some((sub) => sub.isActive); + +for (const sub of active) { + console.log(sub.productId); // Store product/SKU + console.log(sub.currentPlanId); // Base plan or current product ID when available + console.log(sub.purchaseToken); // Verify on your server/IAPKit +} + +const hasPremium = await hasActiveSubscriptions(premiumIds);`} + ), + swift: ( + {`import OpenIAP + +let premiumIds = [ + "dev.hyo.martie.premium", + "dev.hyo.martie.premium_year" +] + +let active = try await OpenIapModule.shared.getActiveSubscriptions(premiumIds) +let canUsePremium = active.contains { $0.isActive } + +for subscription in active { + print(subscription.productId) // Store product ID + print(subscription.currentPlanId) // iOS product ID + print(subscription.transactionId) // Verify on your server/IAPKit +} + +let hasPremium = try await OpenIapModule.shared.hasActiveSubscriptions(premiumIds)`} + ), + kotlin: ( + {`val premiumIds = listOf( + "dev.hyo.martie.premium", + "dev.hyo.martie.premium_year" +) + +val active = openIapStore.getActiveSubscriptions(premiumIds) +val canUsePremium = active.any { it.isActive } + +active.forEach { subscription -> + println(subscription.productId) // Store product/SKU + println(subscription.currentPlanId) // Base plan or current product ID + println(subscription.purchaseToken) // Verify on your server/IAPKit +} + +val hasPremium = openIapStore.hasActiveSubscriptions(premiumIds)`} + ), + dart: ( + {`final premiumIds = [ + 'dev.hyo.martie.premium', + 'dev.hyo.martie.premium_year', +]; + +final active = await FlutterInappPurchase.instance + .getActiveSubscriptions(subscriptionIds: premiumIds); +final canUsePremium = active.any((sub) => sub.isActive); + +final hasPremium = await FlutterInappPurchase.instance + .hasActiveSubscriptions(subscriptionIds: premiumIds);`} + ), + csharp: ( + {`var premiumIds = new List +{ + "dev.hyo.martie.premium", + "dev.hyo.martie.premium_year" +}; + +var active = await ((QueryResolver)Iap.Instance) + .GetActiveSubscriptionsAsync(premiumIds); +var canUsePremium = active.Any(sub => sub.IsActive); + +var hasPremium = await ((QueryResolver)Iap.Instance) + .HasActiveSubscriptionsAsync(premiumIds);`} + ), + }} + +
    + +
    + + Store Behavior + + + + {{ + ios: ( + <> +

    Apple App Store

    +

    + Apple has a real subscription group in App Store Connect, and + StoreKit transactions expose subscriptionGroupID. + OpenIAP keeps that platform detail on iOS purchase objects as{' '} + subscriptionGroupIdIOS. +

    +

    + For active entitlement checks, use productId and{' '} + currentPlanId. On iOS, currentPlanId{' '} + is the current StoreKit product ID, so multiple subscription + groups can return multiple active entries without being merged + together. +

    +
      +
    • + Upgrade/downgrade rules still depend on Apple subscription + group ordering. +
    • +
    • + renewalInfoIOS.autoRenewPreference shows the + product that will renew next. +
    • +
    • + getActiveSubscriptions reads current StoreKit + entitlements and filters by product ID. +
    • +
    + + ), + android: ( + <> +

    Google Play

    +

    + Google Play models subscriptions as subscription products with + base plans and offers. OpenIAP returns the active subscription + product in productId and the base plan in{' '} + currentPlanId / basePlanIdAndroid{' '} + when the billing response provides it. +

    +

    + If your app has multiple subscription products or multiple + plan families, pass all product IDs that unlock the feature to{' '} + getActiveSubscriptions. The SDK does not collapse + different products into one group. +

    +
      +
    • + Use purchaseToken for Play Developer API + verification. +
    • +
    • + Use subscriptionOffers from{' '} + fetchProducts when starting a subscription. +
    • +
    • + For complete lifecycle state, sync RTDN and Play Developer + API data on your server or IAPKit. +
    • +
    + + ), + horizon: ( + <> +

    Meta Horizon OS

    +

    + Horizon uses a Google Billing-compatible API surface, so the + app code stays close to the Google Play path. OpenIAP returns + active subscriptions by productId,{' '} + currentPlanId, and purchaseToken. +

    +

    + There is no cross-store group ID to rely on here. Treat the + Horizon product IDs in your catalog as the entitlement keys + and verify purchases with your backend or IAPKit when access + must be authoritative. +

    + + ), + amazon: ( + <> +

    Amazon Fire OS

    +

    + Amazon Appstore receipts do not expose an Apple-style group + ID. OpenIAP normalizes Fire OS subscriptions to the same + Android shape: productId and{' '} + currentPlanId are the subscription SKU, and{' '} + purchaseToken is the Amazon receipt ID. +

    +

    + During a purchase, the Amazon adapter correlates the + requestId-backed purchase response with the requested SKU, so + example and framework code do not need ad-hoc receipt alias + handling. Restore and cold-start entitlement checks should + still use the store receipt data and server/IAPKit + verification instead of app-local SKU alias storage. +

    +
      +
    • + Keep Amazon product IDs, App Tester data, and IAPKit product + mappings aligned. +
    • +
    • + Call fetchProducts before purchasing so the + adapter can cache product type information. +
    • +
    • + Call getActiveSubscriptions with the same SKU + list you use for Google Play or Horizon. +
    • +
    + + ), + }} +
    +
    + +
    + + Entitlement Design + +

    + For app UI, map your feature to an accepted set of product IDs. For + backend authorization, send purchaseToken /{' '} + transactionId to your server and verify with the relevant + store API or{' '} + + IAPKit + + . The client-side active subscription list is useful for immediate UI + state, but the server should own durable entitlement decisions. +

    + + {`const premiumProductIds = new Set([ + 'dev.hyo.martie.premium', + 'dev.hyo.martie.premium_year', +]); + +function grantsPremium(subscription: ActiveSubscription) { + return subscription.isActive && premiumProductIds.has(subscription.productId); +}`} +
    +
    + ); +} + +export default ActiveSubscriptions; diff --git a/packages/docs/src/pages/docs/features/subscription/index.tsx b/packages/docs/src/pages/docs/features/subscription/index.tsx index 2cc14b08..c592e869 100644 --- a/packages/docs/src/pages/docs/features/subscription/index.tsx +++ b/packages/docs/src/pages/docs/features/subscription/index.tsx @@ -23,6 +23,23 @@ function Subscription() { subscription management in your app.

    +
    + + Subscription State + +

    + After purchase, use{' '} + + Active Subscriptions + {' '} + to check which product or plan is currently active across Apple, + Google Play, Meta Horizon, and Amazon Fire OS. That page explains how + OpenIAP normalizes store-specific subscription group behavior into + productId, currentPlanId, and purchase-token + based entitlement checks. +

    +
    +
    Subscription Offers diff --git a/packages/docs/src/pages/docs/features/validation.tsx b/packages/docs/src/pages/docs/features/validation.tsx index c07a999c..2e098295 100644 --- a/packages/docs/src/pages/docs/features/validation.tsx +++ b/packages/docs/src/pages/docs/features/validation.tsx @@ -17,7 +17,7 @@ function Validation() { title="Validation" description="Validate in-app purchases with your backend or IAPKit. verifyPurchase and verifyPurchaseWithProvider for receipt and JWS verification." path="/docs/features/validation" - keywords="verifyPurchase, purchase validation, IAPKit, receipt verification, server-side validation, JWS verification" + keywords="verifyPurchase, purchase validation, IAPKit, receipt verification, Amazon RVS, Fire OS, Vega OS, server-side validation, JWS verification" />

    Validation

    @@ -105,7 +105,7 @@ const result = await verifyPurchase({ if (result.isValid) { await grantEntitlement(purchase.productId); - await finishTransaction(purchase, false); + await finishTransaction({ purchase, isConsumable: false }); }`} ), swift: ( @@ -179,12 +179,14 @@ if result.is_valid: IAPKit {' '} is an open-source (MIT) receipt-validation service - for App Store and Google Play purchases. Instead of running your own - backend that talks to Apple's App Store Server API and Google Play - Developer API, you forward the JWS / purchase token to IAPKit and get - a normalized verification response — so one-time in-app purchases are - checked against the store's authoritative state. Use the hosted - version at{' '} + for App Store, Google Play, Amazon Appstore, and Meta Horizon + purchases. Instead of running your own backend that talks to each + store's verification API, you forward the JWS, purchase token, Amazon + receipt id, or Horizon entitlement payload to IAPKit and get a + normalized verification response — so one-time in-app purchases are + checked against the store's authoritative state. Amazon Fire OS and + Vega OS both use the iapkit.amazon payload. Use the + hosted version at{' '} VerifyPurchaseWithProviderResult {' '} - shape for Apple and Google. No per-platform JSON parsing. + shape for Apple, Google, Amazon, and Horizon. No per-platform JSON + parsing.

  • - Fraud-resistant — verifies Apple JWS signatures and - queries Google Play's authoritative subscription/purchase state on - the server, blocking common forged receipt and replay flows. + Fraud-resistant — verifies receipts and purchase + state against the store's authoritative server API, blocking common + forged receipt and replay flows.
  • Entitlement state, not raw receipts — IAPKit @@ -290,14 +293,19 @@ const result = await verifyPurchaseWithProvider({ provider: 'iapkit', iapkit: { apiKey: 'openiap-kit_', - apple: { jws: purchase.purchaseToken }, - google: { purchaseToken: purchase.purchaseToken }, + // Choose exactly one store payload. + // apple: { jws: purchase.purchaseToken }, + // google: { purchaseToken: purchase.purchaseToken }, + amazon: { + receiptId: purchase.purchaseToken, + sandbox: __DEV__, + }, }, }); if (result.iapkit?.isValid && result.iapkit?.state === 'entitled') { await grantEntitlement(purchase.productId); - await finishTransaction(purchase, false); + await finishTransaction({ purchase, isConsumable: false }); }`} ), swift: ( @@ -475,7 +483,7 @@ if result.iapkit.is_valid and result.iapkit.state == IapkitPurchaseState.ENTITLE if (result.iapkit?.isValid) { // Verification succeeded - grant access - await finishTransaction(purchase); + await finishTransaction({ purchase, isConsumable: false }); grantAccess(); } else { // Verification returned invalid - actually invalid purchase @@ -488,7 +496,7 @@ if result.iapkit.is_valid and result.iapkit.state == IapkitPurchaseState.ENTITLE console.error('Verification failed:', error); // Fail-open approach: grant access anyway - await finishTransaction(purchase); + await finishTransaction({ purchase, isConsumable: false }); grantAccess(); }`}
  • diff --git a/packages/docs/src/pages/docs/features/vega-os.tsx b/packages/docs/src/pages/docs/features/vega-os.tsx new file mode 100644 index 00000000..12b2e9cf --- /dev/null +++ b/packages/docs/src/pages/docs/features/vega-os.tsx @@ -0,0 +1,590 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +function VegaOSRuntime() { + useScrollToHash(); + + return ( +
    + +

    Vega OS Runtime

    + +

    + Vega OS is not Fire OS and it is not an Android flavor. OpenIAP supports + Vega OS as a runtime-selected JavaScript adapter for React Native for + Vega apps, using Amazon's Vega IAP JavaScript API. +

    +

    + Treat this like the Onside integration layer: the library keeps the + normal native module path for iOS, Android, Fire OS, and Horizon, then + switches to the Vega adapter only when the app is running in the{' '} + kepler runtime. +

    + +
    + + Target Matrix + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TargetLibrarySelection
    Vega OS + react-native-iap, expo-iap + + Runtime adapter selected when the app is running on React Native + for Vega and Platform.OS is kepler +
    Fire OSAndroid, React Native, Expo, Flutter + Android amazon flavor. See{' '} + Fire OS Setup. +
    Flutter, Godot, KMP, MAUI, native AndroidNot Vega targetsNo Vega JavaScript runtime adapter
    +
    + +
    + + Requirements + +
      +
    • + An app build target compatible with Amazon React Native for Vega. + The current public Vega docs center on{' '} + + React Native 0.72 support + + . +
    • +
    • + Amazon Vega IAP installed in the Vega app target.{' '} + react-native-iap and expo-iap declare the + Amazon IAP package as an optional peer dependency, so non-Vega iOS, + Android, Fire OS, and Horizon builds do not install it by default: + {`npm install @amazon-devices/keplerscript-appstore-iap-lib@~2.12.13`} +
    • +
    • + Vega IAP service declarations in manifest.toml: + {`[wants] +[[wants.service]] +id = "com.amazon.iap.core.service" +[[wants.module]] +id = "/com.amazon.iap.core@IIAPCoreUI" + +[needs] +[[needs.module]] +id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService"`} +
    • +
    +
    + +
    + + Setup + +

    + Do not use fireOsEnabled=true as the Vega OS selector. + That Gradle property selects the Fire OS Android flavor only. Use{' '} + amazon.vegaOS for the Amazon Vega runtime target and{' '} + amazon.fireOS only when the same config should also + prepare Fire OS Android builds. +

    +

    + Vega apps need a Kepler-compatible React Native project, a{' '} + manifest.toml, and a Vega build target. The{' '} + expo-iap config plugin can generate that project + metadata. Plain React Native apps should provide the Vega project + files directly, as shown in the OpenIAP repository example. +

    +

    + Keep the React Native for Vega runtime in the Vega app target or a + Vega-only package manifest. In particular, do not keep{' '} + @amazon-devices/react-native-kepler as a direct + dependency of a package manifest that is also used for normal iOS or + Android builds, because React Native Codegen can scan it during those + builds. +

    +

    + If a Vega target keeps{' '} + @amazon-devices/react-native-kepler in{' '} + optionalDependencies, do not omit optional dependencies + during install. The package is still required for Vega builds; it is + optionalized only to keep non-Vega installs and Codegen clean. +

    + +

    + react-native-iap + + # + +

    +

    + Install react-native-iap normally. The package includes a + Vega resolver that loads vega.kepler.ts in the{' '} + kepler runtime. Non-Vega platforms continue creating the + Nitro RnIap HybridObject. +

    +

    + Vega users install the Amazon IAP package in their Vega app target to + satisfy the optional peer dependency. Projects that also ship regular + iOS or Android apps should isolate the Amazon React Native for Vega + runtime packages in the Vega target, as the repository example build + script does with a temporary React Native 0.72 app. +

    +

    + In react-native-iap, the config plugin can select the + Fire OS Android flavor with amazon.fireOS, but it does + not generate Vega project files or automatically sync package.json + dependencies. A plain React Native Vega target should provide its own{' '} + manifest.toml, Kepler entry point, Metro/Babel resolver, + package dependencies, and kepler metadata directly. +

    + {`# In the Vega-only React Native for Vega target +yarn add react-native-iap +yarn add @amazon-devices/keplerscript-appstore-iap-lib@~2.12.13 @amazon-devices/react-native-kepler@^2.0.0 +yarn add -D @amazon-devices/kepler-cli-platform@~0.22.0 @react-native-community/cli@11.3.2 @react-native/metro-config@^0.72.6`} +

    + A Vega-only package manifest can keep the React Native for Vega + runtime as a direct dependency because that manifest is not used by + normal iOS or Android builds: +

    + {`{ + "dependencies": { + "@amazon-devices/keplerscript-appstore-iap-lib": "~2.12.13", + "@amazon-devices/react-native-kepler": "^2.0.0", + "react": "18.2.0", + "react-native": "0.72.0" + }, + "devDependencies": { + "@amazon-devices/kepler-cli-platform": "~0.22.0", + "@react-native-community/cli": "11.3.2", + "@react-native/metro-config": "^0.72.6" + }, + "kepler": { + "projectType": "application", + "appName": "MyVegaApp", + "targets": ["tv"], + "os": ["vega"] + } +}`} +

    + The React Native example includes a Vega build script that creates an + Amazon-supported React Native 0.72 Vega build target, copies the + current package source plus the existing example screens into that + target, and produces an armv7 package for Fire TV + devices. The Vega app opens the same example menu and flows as the + regular React Native example: +

    + {`cd libraries/react-native-iap/example +yarn build:vega:debug + +yarn run:vega:firetv`} + +

    + expo-iap + + # + +

    +

    + Expo projects can use expo-iap to prepare the Vega + project files through the config plugin. The app still has to build + against a React Native for Vega runtime version supported by the + installed Amazon Vega CLI. +

    +

    + Install the Amazon Vega peer dependency only for the Vega build + target. The regular Expo iOS and Android development build should not + require @amazon-devices/react-native-kepler unless that + same package manifest is intentionally building a Vega artifact. Vega + CI should also install optional dependencies so the React Native for + Vega runtime is present for build-vega. +

    + {`[ + 'expo-iap', + { + amazon: { + fireOS: true, + vegaOS: true, + }, + vega: { + packageId: 'dev.example.app', + title: 'Example App', + icon: './assets/images/icon.png', + }, + }, +]`} +

    + When amazon.vegaOS is enabled, the Expo plugin prepares + the Vega manifest, entry point, generated app metadata, app icon + assets, Kepler package metadata, and Vega build scripts during + prebuild. It keeps the Amazon IAP package as a runtime dependency and + the Kepler CLI/Metro/Babel packages as development dependencies, but + syncs @amazon-devices/react-native-kepler as an{' '} + optionalDependency so regular iOS and Android Codegen do + not scan it as a direct React Native dependency.{' '} + amazon.fireOS can be enabled in the same config, but Fire + OS and Vega OS still produce separate build artifacts. +

    + {`EXPO_IAP_VEGA=1 expo prebuild --platform android --no-install +EXPO_IAP_VEGA=1 react-native build-vega --build-type Debug`} +

    + The Expo example also includes a Vega build script for testing the + current local expo-iap source against an Amazon-supported + React Native 0.72 Vega build target. The script copies the existing + Expo Router routes and example components into the temporary Vega app, + then uses lightweight shims only for Expo Router navigation and + Expo-only helper modules: +

    + {`cd libraries/expo-iap/example +bun run build:vega:debug + +bun run run:vega:firetv`} +

    + For local IAPKit validation while testing on a physical Fire TV + device, run the Kit API server on the Mac and build the example with a + LAN-reachable base URL. A Fire TV device cannot reach the Mac through{' '} + localhost, so use the Mac's Wi-Fi IP address: +

    + {`# Terminal 1: local Kit API server +cd packages/kit +PORT=3100 bun --env-file=.env.local ./server/server.ts + +# Terminal 2: React Native example +cd libraries/react-native-iap +IAPKIT_API_KEY=openiap-kit_ \\ +IAPKIT_BASE_URL=http://:3100 \\ + yarn workspace rn-iap-example build:vega:debug + +# Terminal 2: Expo example +cd libraries/expo-iap/example +EXPO_PUBLIC_IAPKIT_API_KEY=openiap-kit_ \\ +EXPO_PUBLIC_IAPKIT_BASE_URL=http://:3100 \\ + bun run build:vega:debug`} +

    + Include the matching IAPKit API key environment variable when testing + server verification: IAPKIT_API_KEY for the React Native + example, or EXPO_PUBLIC_IAPKIT_API_KEY for the Expo + example. +

    +

    + Amazon's Vega run-app documentation uses the interactive component ID + as the app id. For physical Fire TV devices, replace{' '} + <device-serial> with the serial shown by{' '} + vega device list. For VVD, use the architecture-specific + package that matches the virtual device. +

    + {`vega device list + +vega device -d install-app \\ + --packagePath build/armv7-debug/_armv7.vpkg + +vega device -d launch-app \\ + --appName `} +

    + Some Fire TV devices show a five-digit parental-control PIN prompt + before the app is foregrounded. If remote number key events do not + complete the prompt, send the PIN digits through VDA. This example + enters 01234: +

    + {`for key in KEY_0 KEY_1 KEY_2 KEY_3 KEY_4; do + vega exec vda -s shell inputd-cli button_press "$key" +done`} +
    + +
    + + App Tester Sandbox + +

    + Local Vega IAP testing uses Amazon App Tester in sandbox mode. Keep + the App Tester catalog file and the app sandbox config file separate: + App Tester reads amazon.sdktester.json, while the app + reads amazon.config.json. +

    +

    + Amazon's{' '} + + App Tester configuration documentation + {' '} + requires the tester service and UI module in{' '} + manifest.toml for sandbox testing: +

    + {`[wants] +[[wants.service]] +id = "com.amazon.iap.tester.service" + +[[wants.module]] +id = "/com.amazonappstore.iap.tester@IIAPTesterUI"`} +

    Push the App Tester catalog to the App Tester scratch directory:

    + {`vega exec vda -s shell \\ + mkdir -p /tmp/scratch/com.amazonappstore.iap.tester + +vega device copy-to -d \\ + -s amazon.sdktester.json \\ + -o /tmp/scratch/com.amazonappstore.iap.tester`} +

    + Enable sandbox mode for the Vega application id with this app-local + config. Use the id passed to run-app or{' '} + launch-app, for example the interactive component id in{' '} + manifest.toml. +

    + {`{ + "debug.amazon.sandboxmode": "debug" +}`} + {`vega exec vda -s shell \\ + mkdir -p /tmp/scratch/ + +vega device copy-to -d \\ + -s amazon.config.json \\ + -o /tmp/scratch/`} +

    + Relaunch App Tester after changing amazon.sdktester.json, + and relaunch the test app after changing{' '} + amazon.config.json, following Amazon's{' '} + + App Tester usage documentation + + . In sandbox mode, IAP logs should report the debug sandbox mode and + App Tester responses instead of production-mode catalog responses. +

    + {`vega exec vda -s shell \\ + vlcm terminate-app --pkg-id com.amazonappstore.iap.tester --force + +vega exec vda -s shell \\ + vlcm launch-app pkg://com.amazonappstore.iap.tester.ui + +vega device -d terminate-app --appName +vega device -d launch-app --appName `} +

    + OpenIAP's repository examples include matching{' '} + amazon.sdktester.json and amazon.config.json{' '} + files for the example product IDs so the All Products and + purchase-flow screens can be tested against App Tester. +

    +

    + If App Tester shows the JSON catalog, the sandbox user is logged in, + and getProductData or getPurchaseUpdates{' '} + still returns FAILED, check the installed Amazon Vega IAP + package/runtime before changing OpenIAP product IDs. A public{' '} + + Amazon Developer Community report + {' '} + tracks this behavior with{' '} + @amazon-devices/keplerscript-appstore-iap-lib@2.12.13. + OpenIAP surfaces those responses as product or purchase loading errors + so the app can stay open while the Amazon-side package/runtime issue + is investigated. +

    +
    + +
    + + Usage + +

    + App code keeps the standard OpenIAP API shape. The runtime adapter + maps the calls to Amazon's Vega IAP service. +

    + {`import { + fetchProducts, + finishTransaction, + initConnection, + requestPurchase, + verifyPurchaseWithProvider, +} from 'react-native-iap'; // or 'expo-iap' + +await initConnection(); + +const products = await fetchProducts({ + skus: ['coins_100'], + type: 'in-app', +}); + +await requestPurchase({ + request: { + google: { + skus: ['coins_100'], + }, + }, + type: 'in-app', +}); + +// In the purchase success path: +const verification = await verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: '', + amazon: { + receiptId: purchase.purchaseToken ?? '', + sandbox: __DEV__, + }, + }, +}); + +if (verification.iapkit?.isValid !== true) { + throw new Error('IAPKit could not verify the Amazon receipt'); +} + +await finishTransaction({ purchase, isConsumable: true });`} +

    + Vega OS uses the same IAPKit Amazon payload as Fire OS. The Vega + adapter sends store: 'amazon' to IAPKit and can resolve + the Amazon user id from Vega user data when amazon.userId{' '} + is omitted. +

    +
    + +
    + + API Mapping + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OpenIAP APIVega JavaScript IAP API
    + initConnection() + + Marks the Vega adapter ready without fetching Amazon user data +
    + getStorefront() + + PurchasingService.getUserData +
    + fetchProducts() + + PurchasingService.getProductData +
    + requestPurchase() + + PurchasingService.purchase +
    + getAvailablePurchases(),{' '} + restorePurchases() + Internal Amazon purchase update read
    + finishTransaction() + + PurchasingService.notifyFulfillment +
    +
    + +
    + + Current Limitations + +
      +
    • + Vega OS support is limited to React Native for Vega. OpenIAP exposes + that path through react-native-iap and through{' '} + expo-iap config/plugin support for compatible Expo + projects. +
    • +
    • + Vega OS uses Amazon's JavaScript IAP API, not the Fire OS Android + Appstore SDK. +
    • +
    • + Server-side Amazon Receipt Verification Service integration is not + embedded in the client package. Use IAPKit's Amazon verification + path, or run your own server-side RVS integration. +
    • +
    • + The OpenIAP store remains amazon for compatibility, + while runtime selection remains Vega-specific. +
    • +
    • + Complete build-vega bundling requires a React Native + version supported by the installed Amazon Vega CLI. If the CLI + rejects the app's React Native version, amazon.vegaOS{' '} + can still prepare and validate the Vega project files, but the app + needs an Amazon-supported React Native for Vega build target. +
    • +
    • + The repository Vega examples intentionally build through temporary + React Native 0.72 Vega projects so they can test the current OpenIAP + package source and the existing example UI while staying inside + Amazon's supported Vega runtime version. +
    • +
    +
    +
    + ); +} + +export default VegaOSRuntime; diff --git a/packages/docs/src/pages/docs/fireos-setup.tsx b/packages/docs/src/pages/docs/fireos-setup.tsx new file mode 100644 index 00000000..35073165 --- /dev/null +++ b/packages/docs/src/pages/docs/fireos-setup.tsx @@ -0,0 +1,376 @@ +import { Link } from 'react-router-dom'; +import CodeBlock from '../../components/CodeBlock'; +import SEO from '../../components/SEO'; + +function FireOSSetup() { + return ( +
    + +

    Fire OS Setup Guide

    +

    + Fire OS support targets Android apps distributed through the Amazon + Appstore. OpenIAP supports it through the Android package's{' '} + amazon flavor, backed by the Amazon Appstore SDK. +

    +

    + Vega OS is a separate JavaScript runtime target for{' '} + react-native-iap and expo-iap. It does not use + this Android flavor. See the{' '} + Vega OS Runtime guide for that + setup. +

    + +
    +

    + Platform Boundary + + # + +

    +
      +
    • + Fire OS: Android app target using the Amazon Appstore SDK via the{' '} + amazon flavor. +
    • +
    • + Artifact: openiap-google-amazon for native Android + consumers. +
    • +
    • + Config plugin option: amazon.fireOS=true selects the + Fire OS Android flavor during prebuild in expo-iap and{' '} + react-native-iap. +
    • +
    • + Gradle/runtime flag: fireOsEnabled=true selects the + Fire OS Android flavor in React Native and Flutter builds. +
    • +
    • + Vega OS: not an Android flavor. Use amazon.vegaOS=true{' '} + to mark the Amazon Vega runtime target; Expo projects can also + generate Vega metadata from that option. +
    • +
    +
    + +
    +

    + Fire OS + + # + +

    +

    + Use the Amazon artifact when consuming the native Android package + directly for Fire OS: +

    + {`dependencies { + implementation("io.github.hyochan.openiap:openiap-google-amazon:$version") +}`} +

    + In the monorepo or a local Gradle composite build, select the Amazon + flavor: +

    + {`android { + defaultConfig { + missingDimensionStrategy("platform", "amazon") + } +}`} +

    Fire OS apps also need the usual Amazon Appstore setup:

    +
      +
    • + An Amazon Developer account with an Amazon Appstore app record. +
    • +
    • + In-app items configured in the Amazon Developer Console. OpenIAP + maps Amazon consumables and entitlements to in-app, and + subscriptions to subs. +
    • +
    • + Product IDs in Amazon Appstore and Amazon App Tester should match + the SKUs your app passes to fetchProducts and{' '} + requestPurchase. For subscriptions, keep each Amazon + subscription group or term aligned with the same app-facing SKU you + use on Apple, Google, Horizon OS, and Kit entitlement checks. +
    • +
    • + Amazon App Tester installed on a Fire OS or compatible Android test + device for sandbox testing. +
    • +
    • + Amazon Appstore public key copied into the Android app's{' '} + src/main/assets directory. +
    • +
    +
    + +
    +

    + Catalog Identity + + # + +

    +

    + Treat the SKU that your app requests as the canonical entitlement + identity. Apple, Google Play, Horizon OS, and Amazon all have store + console concepts for grouping subscription products or terms, but the + app should still receive one stable OpenIAP productId for + the item it requested. +

    + + + + + + + + + + + + + + + + + + + + + +
    Store setupOpenIAP app identity
    Apple subscription group + Use the App Store product ID as the SKU passed to OpenIAP. +
    Google subscription with base plans or offers + Use the subscription product ID as productId, then + read plan details from subscription offers. +
    Amazon subscription group or term + Use the Amazon SKU that appears in product data and purchase + requests as the OpenIAP productId. Do not rely on a + separate test-catalog alias for entitlement checks. +
    +
    + If Amazon App Tester or the Amazon catalog returns a receipt SKU that + differs from the SKU your app requested, immediate client-side + purchase updates can keep the requested SKU for the in-flight + response, but restore and server verification flows still depend on + the store catalog identity. Keep Amazon product IDs, tester JSON, and + IAPKit product mappings aligned, then check subscription state through{' '} + getActiveSubscriptions,{' '} + getAvailablePurchases, or Kit entitlement APIs. +
    +

    + With that catalog identity in place, Fire OS subscription checks use + the same OpenIAP calls as the other stores. Apps call{' '} + getActiveSubscriptions with their subscription SKUs and + read productId, currentPlanId, and{' '} + isActive from the returned{' '} + ActiveSubscription + records instead of writing Amazon-specific receipt mapping code. +

    +
    + +
    +

    + Fire OS Frameworks + + # + +

    +

    + Expo and React Native config plugins use amazon.fireOS. + Flutter builds, or React Native builds that do not run a config + plugin, can select the same Android flavor through the{' '} + fireOsEnabled Gradle property and the app module's{' '} + missingDimensionStrategy: +

    + {`fireOsEnabled=true +# Do not set horizonEnabled=true in the same build.`} +

    + Expo and React Native config plugins can write the same Android + selection during prebuild: +

    + {`// Expo +plugins: [['expo-iap', { amazon: { fireOS: true } }]] + +// React Native config plugin +plugins: [['react-native-iap', { amazon: { fireOS: true } }]]`} +

    + For Flutter, read the property from{' '} + android/gradle.properties in{' '} + android/app/build.gradle and select the plugin flavor: +

    + {`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 + } +}`} +

    + The framework code continues to call the normal cross-platform APIs: +

    + {`await initConnection(); + +const products = await fetchProducts({ + skus: ['coins_100'], + type: 'in-app', +}); + +await requestPurchase({ + request: { + google: { + skus: ['coins_100'], + }, + }, + type: 'in-app', +});`} +
    + +
    +

    + Fire OS Flows + + # + +

    +
      +
    • + + initConnection + {' '} + registers the Amazon IAP listener and requests Amazon user data. +
    • +
    • + + fetchProducts + {' '} + calls PurchasingService.getProductData and maps + consumables, entitlements, and subscriptions into OpenIAP Android + product types. +
    • +
    • + + requestPurchase + {' '} + launches Amazon's purchase flow. Amazon accepts one SKU per purchase + request. +
    • +
    • + + getAvailablePurchases + {' '} + and{' '} + + restorePurchases + {' '} + stay on the OpenIAP surface while the Amazon adapter internally + reads purchase updates from the Amazon Appstore SDK. +
    • +
    • + + finishTransaction + + ,{' '} + + acknowledgePurchaseAndroid + + , and{' '} + + consumePurchaseAndroid + {' '} + call Amazon fulfillment with the receipt ID. +
    • +
    +
    + +
    +

    + IAPKit Verification + + # + +

    +

    + Fire OS purchases can use IAPKit's Amazon verification path instead of + app-owned receipt-verification infrastructure. Pass the Amazon receipt + id through iapkit.amazon.receiptId; the Amazon Android + flavor can resolve userId from Amazon user data when it + is omitted. Use sandbox: true for Amazon App Tester + receipts. +

    + {`const result = await verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: '', + amazon: { + receiptId: purchase.purchaseToken ?? '', + sandbox: __DEV__, + }, + }, +});`} +

    + See the IAPKit backend guide for + the shared Fire OS and Vega OS Amazon verification flow. +

    +
    + +
    +

    + Current Limitations + + # + +

    +
      +
    • + Server-side Amazon Receipt Verification Service integration is not + embedded in the Android client package. Use IAPKit's Amazon + verification path, or run your own server-side RVS integration. +
    • +
    • + Fire OS sandbox testing still requires Amazon App Tester setup and + the Android sandbox property, for example{' '} + adb shell setprop debug.amazon.sandboxmode debug. Vega + OS sandbox testing uses the app-local{' '} + amazon.config.json flow documented in the{' '} + Vega OS Runtime guide. +
    • +
    • + Vega OS is intentionally not included in the Fire OS Android flavor. + Use the Vega OS Runtime{' '} + guide for React Native and Expo Vega apps. +
    • +
    • + Godot, KMP, and MAUI currently receive the shared Amazon store enum + and type documentation, but their Android wrappers do not yet expose + an Amazon flavor switch. +
    • +
    • + Google Play billing programs, alternative billing, and subscription + billing issue events are Play-only and return unsupported or no-op + results on Fire OS. +
    • +
    • + Amazon reports both user-cancelled purchases and some generic + purchase failures with FAILED. OpenIAP maps that status + to a cancellation-style purchase error because Amazon does not + expose a more specific client-side code. +
    • +
    +
    +
    + ); +} + +export default FireOSSetup; diff --git a/packages/docs/src/pages/docs/foundation/sponsorship.tsx b/packages/docs/src/pages/docs/foundation/sponsorship.tsx index df6794d9..7e95b887 100644 --- a/packages/docs/src/pages/docs/foundation/sponsorship.tsx +++ b/packages/docs/src/pages/docs/foundation/sponsorship.tsx @@ -111,7 +111,7 @@ function Sponsorship() { Platform policy response - When Apple or Google change billing APIs, we update the spec so + When source stores change billing APIs, we update the spec so you don't scramble diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index 8468d987..6dbe8750 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -100,6 +100,7 @@ import Errors from './errors'; import Purchase from './features/purchase'; import SubscriptionFeature from './features/subscription/index'; import SubscriptionUpgradeDowngrade from './features/subscription/upgrade-downgrade'; +import SubscriptionActiveSubscriptions from './features/subscription/active-subscriptions'; import Discount from './features/discount'; import OfferCodeRedemption from './features/offer-code-redemption'; import ExternalPurchase from './features/external-purchase'; @@ -107,11 +108,14 @@ import SubscriptionBillingIssue from './features/subscription-billing-issue'; import Refund from './features/refund'; import Validation from './features/validation'; import Debugging from './features/debugging'; +import RuntimeIntegrations from './features/runtime-integrations'; import AlternativeMarketplace from './features/alternative-marketplace/index'; import AlternativeMarketplaceOnside from './features/alternative-marketplace/onside'; +import VegaOSRuntime from './features/vega-os'; import IOSSetup from './ios-setup'; import AndroidSetup from './android-setup'; import HorizonSetup from './horizon-setup'; +import FireOSSetup from './fireos-setup'; import SetupIndex from './setup/index'; import ReactNativeSetup from './setup/react-native'; import ExpoSetup from './setup/expo'; @@ -596,7 +600,7 @@ function Docs() { className={({ isActive }) => (isActive ? 'active' : '')} onClick={closeSidebar} > - Kit Backend + Purchase Verification
  • @@ -623,7 +627,10 @@ function Docs() { @@ -740,13 +751,17 @@ function Docs() {
  • @@ -1179,6 +1194,10 @@ function Docs() { path="features/subscription/upgrade-downgrade" element={} /> + } + /> } /> } /> } /> } /> + } + /> } @@ -1203,9 +1226,11 @@ function Docs() { path="features/alternative-marketplace/onside" element={} /> + } /> } /> } /> } /> + } /> } /> } /> } /> @@ -1214,6 +1239,26 @@ function Docs() { } /> } /> } /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> } /> } /> diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx index 615e8f37..c9fad9b5 100644 --- a/packages/docs/src/pages/docs/kit-backend.tsx +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -4,25 +4,39 @@ import LanguageTabs from '../../components/LanguageTabs'; import SEO from '../../components/SEO'; import { useScrollToHash } from '../../hooks/useScrollToHash'; +const IAPKIT_URL = 'https://kit.openiap.dev'; + function KitBackend() { useScrollToHash(); return (
    -

    kit backend

    +

    Purchase Verification

    +

    + Purchase verification is the step that proves a store transaction is + real before your app grants paid access, and IAPKit ( + + kit.openiap.dev + + ) is OpenIAP's hosted backend for that flow. Drop it in instead of + running your own server for the steps that come after a user taps "buy": + store verification, lifecycle webhooks, subscription state, revenue + metrics, and App Store Connect / Play Console product sync. Everything + is exposed through one URL surface that the framework SDKs and MCP + server speak. +

    - kit (kit.openiap.dev) is the hosted backend you can drop in - instead of running your own server. It handles every step that comes - after a user taps "buy" — receipt validation, lifecycle webhooks, - subscription state, revenue metrics, and App Store Connect / Play - Console product sync — and exposes everything through one URL surface - that the framework SDKs and MCP server speak. + Amazon targets use the same IAPKit Amazon verification shape. Fire OS + purchases from the Android amazon flavor and Vega OS + purchases from the Kepler runtime both resolve an Amazon user id and + receipt id through iapkit.amazon, then IAPKit verifies them + through Amazon RVS and stores the result under amazon.

    @@ -30,7 +44,7 @@ function KitBackend() { Surface map

    - Receipt verification uses an Authorization: Bearer API + Purchase verification uses an Authorization: Bearer API key header. Webhook, subscription, product, and MCP-friendly endpoints carry the project API key as a path segment so store consoles, SDK helpers, and stdio MCP tools can call them without custom bearer @@ -38,8 +52,9 @@ function KitBackend() {

    • - POST /v1/purchase/verify — receipt validation (Apple - JWS, Google purchaseToken, Meta Horizon) with a Bearer API key. + POST /v1/purchase/verify — purchase verification (Apple + JWS, Google purchaseToken, Amazon RVS receiptId for Fire OS and Vega + OS, Meta Horizon) with a Bearer API key.
    • POST /v1/webhooks/{apiKey} — unified App @@ -123,8 +138,11 @@ function KitBackend() { Dashboard UX

      - The hosted dashboard at kit.openiap.dev wires every - project-scoped endpoint into a UI: + The hosted dashboard at{' '} + + kit.openiap.dev + {' '} + wires every project-scoped endpoint into a UI:

      • @@ -141,11 +159,195 @@ function KitBackend() {
      • Webhooks — copyable lifecycle webhook URL, the SSE stream URL, and a curl recipe for emitting a synthetic test - notification without going through the App Store / Play Console. + notification without opening a store console.
    +
    + + Purchase verification from SDKs + +

    + Most app flows should use the framework SDK's{' '} + verifyPurchaseWithProvider helper instead of constructing{' '} + POST /v1/purchase/verify payloads by hand. The helper + sends the store token to IAPKit and returns a typed{' '} + VerifyPurchaseWithProviderResult. The API is named{' '} + verifyPurchaseWithProvider in the SDKs; the snippets + below call it directly. +

    +

    + For Fire OS and Vega OS, choose the Amazon branch and pass the Amazon + receipt ID. The SDK resolves the Amazon user ID from the runtime when + available. Set sandbox: true when validating Amazon App + Tester sandbox receipts. +

    + + {{ + typescript: ( + {`import { Platform } from 'react-native'; +import { verifyPurchaseWithProvider } from 'expo-iap'; +// Same API in react-native-iap. + +const token = purchase.purchaseToken ?? ''; +const runtimeOS = Platform.OS as string; +const isFireOSBuild = process.env.EXPO_PUBLIC_STORE === 'amazon'; +const isAmazonRuntime = runtimeOS === 'kepler' || isFireOSBuild; +const result = await verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + // Optional when configured via Expo config / Info.plist / AndroidManifest. + apiKey: process.env.EXPO_PUBLIC_IAPKIT_API_KEY, + ...(Platform.OS === 'ios' + ? { apple: { jws: token } } + : isAmazonRuntime + ? { + amazon: { + receiptId: token, + sandbox: __DEV__, + }, + } + : { google: { purchaseToken: token } }), + }, +}); + +if (result.iapkit?.isValid === true) { + await grantEntitlement(purchase.productId); +}`} + ), + swift: ( + {`import OpenIap + +let result = try await OpenIapStore.shared.verifyPurchaseWithProvider( + VerifyPurchaseWithProviderProps( + iapkit: RequestVerifyPurchaseWithIapkitProps( + apiKey: iapkitApiKey, + apple: RequestVerifyPurchaseWithIapkitAppleProps( + jws: purchase.purchaseToken ?? "" + ) + ), + provider: .iapkit + ) +) + +if result?.isValid == true { + unlockEntitlement(productId: purchase.productId) +}`} + ), + kotlin: ( + {`import dev.hyo.openiap.* + +val result = module.verifyPurchaseWithProvider( + VerifyPurchaseWithProviderProps( + provider = PurchaseVerificationProvider.Iapkit, + iapkit = RequestVerifyPurchaseWithIapkitProps( + apiKey = iapkitApiKey, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = purchase.purchaseToken.orEmpty(), + ), + // Fire OS: use amazon = RequestVerifyPurchaseWithIapkitAmazonProps(...) + // with userId, receiptId, and sandbox for Amazon App Tester. + ), + ), +) + +if (result.iapkit?.isValid == true) { + unlockEntitlement(purchase.productId) +}`} + ), + dart: ( + {`import 'dart:io'; +import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; + +final result = await FlutterInappPurchase.instance.verifyPurchaseWithProvider( + VerifyPurchaseWithProviderProps( + provider: PurchaseVerificationProvider.iapkit, + iapkit: RequestVerifyPurchaseWithIapkitProps( + apiKey: IapConstants.iapkitApiKey, + apple: Platform.isIOS + ? RequestVerifyPurchaseWithIapkitAppleProps( + jws: purchase.purchaseToken ?? '', + ) + : null, + google: Platform.isAndroid + ? RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken: purchase.purchaseToken ?? '', + ) + : null, + // Fire OS builds can pass amazon with userId, receiptId, and sandbox. + ), + ), +); + +if (result.iapkit?.isValid == true) { + unlockEntitlement(purchase.productId); +}`} + ), + csharp: ( + {`using OpenIap; +using OpenIap.Maui; + +var token = purchase.PurchaseToken ?? string.Empty; +var mutate = (MutationResolver)Iap.Instance; +var result = await mutate.VerifyPurchaseWithProviderAsync( + new VerifyPurchaseWithProviderProps + { + Provider = PurchaseVerificationProvider.Iapkit, + Iapkit = new RequestVerifyPurchaseWithIapkitProps + { + ApiKey = iapkitApiKey, + Apple = new RequestVerifyPurchaseWithIapkitAppleProps { Jws = token }, + Google = new RequestVerifyPurchaseWithIapkitGoogleProps { PurchaseToken = token }, + // Amazon Fire OS uses Amazon = new RequestVerifyPurchaseWithIapkitAmazonProps { ... }. + }, + }); + +if (result.Iapkit?.IsValid == true) +{ + UnlockEntitlement(purchase.ProductId); +}`} + ), + kmp: ( + {`import io.github.hyochan.kmpiap.* + +val token = purchase.purchaseToken.orEmpty() +val result = kmpIAP.verifyPurchaseWithProvider( + VerifyPurchaseWithProviderProps( + provider = PurchaseVerificationProvider.Iapkit, + iapkit = RequestVerifyPurchaseWithIapkitProps( + apiKey = iapkitApiKey, + apple = if (isIos) RequestVerifyPurchaseWithIapkitAppleProps(jws = token) else null, + google = if (!isIos) RequestVerifyPurchaseWithIapkitGoogleProps(purchaseToken = token) else null, + // Amazon Fire OS builds use amazon with userId, receiptId, and sandbox. + ), + ), +) + +if (result.iapkit?.isValid == true) { + unlockEntitlement(purchase.productId) +}`} + ), + gdscript: ( + {`var result = await iap.verify_purchase_with_provider({ + "provider": "iapkit", + "iapkit": { + "apiKey": iapkit_api_key, + "google": { + "purchaseToken": purchase.get("purchaseToken", ""), + }, + # iOS: use "apple": { "jws": token } + # Fire OS: use "amazon": { "userId": user_id, "receiptId": receipt_id } + }, +}) + +if result.iapkit != null and result.iapkit.is_valid: + unlock_entitlement(purchase.get("productId", ""))`} + ), + }} + +
    +
    Entitlement checks by userId @@ -160,7 +362,8 @@ function KitBackend() { public identifiers like email addresses.

    - SDK helpers are available so you don't have to construct URLs by hand: + Status and entitlement helpers are available in the TypeScript and + MAUI SDKs so those clients do not have to construct URLs by hand:

    {{ diff --git a/packages/docs/src/pages/docs/setup/expo.tsx b/packages/docs/src/pages/docs/setup/expo.tsx index fb6c60b8..a69bcfda 100644 --- a/packages/docs/src/pages/docs/setup/expo.tsx +++ b/packages/docs/src/pages/docs/setup/expo.tsx @@ -133,18 +133,18 @@ function ExpoSetup() {

    - expo-iap uses Google Play Billing Library v - {GOOGLE_PLAY_BILLING.version}, which requires{' '} - Kotlin 2.0+. + expo-iap uses OpenIAP Android artifacts backed by Google Play Billing + Library v{GOOGLE_PLAY_BILLING.version}. Use{' '} + Kotlin 2.2+ for Android builds.

    • - Expo SDK 54+: No configuration needed — Kotlin 2.0+ - is included by default. + Expo SDK 54+: Use the default toolchain when it is + already Kotlin 2.2 compatible, or set the version explicitly below.
    • - Expo SDK 53: Kotlin 2.0+ is included natively, but - if you encounter build issues, explicitly set the Kotlin version: + Expo SDK 53: Explicitly set the Kotlin version when + building Android apps:
    @@ -288,7 +288,11 @@ cd ios && pod install`} "onside": true, "horizon": true }, - "google": { + "amazon": { + "fireOS": false, + "vegaOS": false + }, + "android": { "horizonAppId": "YOUR_HORIZON_APP_ID" } } @@ -297,6 +301,24 @@ cd ios && pod install`} } }`} +

    + Amazon targets are grouped under amazon.{' '} + amazon.fireOS selects the Android Amazon Appstore flavor, + while amazon.vegaOS prepares Kepler/Vega project files. + They can both be true in one config, but Fire OS and Vega + OS are still built as separate artifacts. +

    +

    + Vega OS support uses optional peer dependencies. Install Amazon's Vega + IAP package only in the Vega app target. When{' '} + amazon.vegaOS is enabled, the plugin keeps the Kepler + CLI, Metro, and Babel packages available for build-vega, + but syncs @amazon-devices/react-native-kepler as an{' '} + optionalDependency. Keep that package out of normal{' '} + dependencies and devDependencies used by + regular Expo iOS or Android builds, and make sure Vega CI installs + optional dependencies before running build-vega. +

    @@ -338,7 +360,29 @@ cd ios && pod install`} + + + + + + + + + + @@ -383,7 +427,7 @@ function Store() { // 2. Grant entitlement // 3. CRITICAL: Finish the transaction // (Android auto-refunds after 3 days if not called!) - await finishTransaction(purchase, false); // true for consumables + await finishTransaction({ purchase, isConsumable: false }); // true for consumables }, onPurchaseError: (error) => { if (error.code === ErrorCode.UserCancelled) return; @@ -654,9 +698,9 @@ EXPO_TV=1 npx expo run:ios --device "Apple TV 4K (3rd generation)"`} }} > Warning: Expo SDK 52 (React Native 0.76.x) uses - Kotlin 1.9.x, which is incompatible with Google Play Billing Library - v8 (requires Kotlin 2.0+). Upgrading to SDK 53+ is - the recommended solution. + Kotlin 1.9.x, which is incompatible with the current OpenIAP Android + artifacts. Upgrading to SDK 53+ and setting Kotlin + 2.2.0 is the recommended solution.

    diff --git a/packages/docs/src/pages/docs/setup/flutter.tsx b/packages/docs/src/pages/docs/setup/flutter.tsx index 6f4da438..5f3c59d5 100644 --- a/packages/docs/src/pages/docs/setup/flutter.tsx +++ b/packages/docs/src/pages/docs/setup/flutter.tsx @@ -127,8 +127,11 @@ function FlutterSetup() { > Note: The missingDimensionStrategy{' '} configuration is required since v7.1.14 due to product flavor support - for Meta Horizon OS. For Meta Quest support, see the{' '} - Horizon OS Setup Guide. + for Meta Horizon OS and Fire OS. For Meta Quest support, see the{' '} + Horizon OS Setup Guide. For Amazon + Fire OS builds, map fireOsEnabled=true to the{' '} + amazon flavor in your app Gradle file and see the{' '} + Fire OS Setup Guide.

    ProGuard Rules (if using ProGuard)

    @@ -310,6 +313,14 @@ final allPurchases = await iap.getAvailablePurchases( Horizon OS Setup — Meta Quest in-app purchase configuration +
  • + Fire OS Setup — Fire OS Android + flavor configuration +
  • +
  • + Vega OS Runtime — React Native + / Expo only; not a Flutter target +
  • Horizon OS Setup — Meta Quest developer dashboard for the Horizon Store
  • +
  • + Fire OS Setup — Android{' '} + amazon flavor for Amazon Appstore distribution +
  • +
  • + Vega OS Runtime — React + Native / Expo JavaScript adapter for Amazon Vega apps +
  • diff --git a/packages/docs/src/pages/docs/setup/react-native.tsx b/packages/docs/src/pages/docs/setup/react-native.tsx index c547cf34..c9574fdf 100644 --- a/packages/docs/src/pages/docs/setup/react-native.tsx +++ b/packages/docs/src/pages/docs/setup/react-native.tsx @@ -16,7 +16,8 @@ function ReactNativeSetup() {

    react-native-iap provides in-app purchase support for React Native apps using Nitro Modules. It supports StoreKit 2 on iOS and - Google Play Billing {GOOGLE_PLAY_BILLING.version}+ on Android. + Google Play Billing {GOOGLE_PLAY_BILLING.version}+ on Android by + default, with optional Horizon and Fire OS Android flavors.

    +
  • + For Fire OS builds, use amazon.fireOS=true with the{' '} + react-native-iap config plugin, or set{' '} + fireOsEnabled=true in{' '} + android/gradle.properties when configuring Gradle + directly. See the{' '} + Fire OS Setup Guide. +
  • +
  • + For Vega OS, do not use an Android flavor. Create a React Native for + Vega target with its own package manifest, install Amazon's Vega + packages only in that target, and follow the{' '} + Vega OS Runtime guide. +
  • + +

    + Vega OS + + # + +

    +

    + react-native-iap declares Amazon Vega runtime packages as + optional peer dependencies, so normal iOS, Android, Fire OS, and + Horizon installs do not need to install them. Unlike{' '} + expo-iap, the react-native-iap config plugin + does not generate a Vega manifest.toml, entry file, build + scripts, or package dependency sync during prebuild. +

    +

    + Plain React Native apps should keep Vega dependencies in a Vega-only + package manifest. The Kepler CLI package must be available as a + development dependency in that Vega target so the React Native CLI can + discover build-vega. Keep{' '} + @amazon-devices/react-native-kepler out of normal + iOS/Android dependencies and devDependencies{' '} + to avoid regular React Native Codegen scanning it. +

    + {`# In the Vega-only React Native for Vega target +yarn add react-native-iap +yarn add @amazon-devices/keplerscript-appstore-iap-lib@~2.12.13 @amazon-devices/react-native-kepler@^2.0.0 +yarn add -D @amazon-devices/kepler-cli-platform@~0.22.0 @react-native-community/cli@11.3.2 @react-native/metro-config@^0.72.6`} +

    + A Vega-only package manifest can keep the React Native for Vega + runtime as a direct dependency because that manifest is not used by + normal iOS or Android builds: +

    + {`{ + "dependencies": { + "@amazon-devices/keplerscript-appstore-iap-lib": "~2.12.13", + "@amazon-devices/react-native-kepler": "^2.0.0", + "react": "18.2.0", + "react-native": "0.72.0" + }, + "devDependencies": { + "@amazon-devices/kepler-cli-platform": "~0.22.0", + "@react-native-community/cli": "11.3.2", + "@react-native/metro-config": "^0.72.6" + }, + "kepler": { + "projectType": "application", + "appName": "MyVegaApp", + "targets": ["tv"], + "os": ["vega"] + } +}`} +

    + The repository example follows this isolation model by generating a + temporary React Native 0.72 Vega project before running{' '} + react-native build-vega: +

    + {`cd libraries/react-native-iap/example +yarn build:vega:debug +yarn run:vega:firetv`}
    @@ -228,7 +303,7 @@ function Store() { // 2. Grant entitlement // 3. CRITICAL: Finish the transaction // (Android auto-refunds after 3 days if not called!) - await finishTransaction(purchase, false); // true for consumables + await finishTransaction({ purchase, isConsumable: false }); // true for consumables }, onPurchaseError: (error) => { if (error.code === ErrorCode.UserCancelled) return; @@ -339,7 +414,7 @@ await initConnection(); const purchaseSub = purchaseUpdatedListener(async (purchase) => { // Validate on server, then finish transaction // CRITICAL: Android auto-refunds after 3 days if not called! - await finishTransaction(purchase, false); // true for consumables + await finishTransaction({ purchase, isConsumable: false }); // true for consumables }); const errorSub = purchaseErrorListener((error) => { @@ -425,6 +500,14 @@ switch (error.code) { Horizon OS Setup — Meta Quest in-app purchase configuration +
  • + Fire OS Setup — Fire OS Android + flavor configuration +
  • +
  • + Vega OS Runtime — React Native + for Vega runtime adapter +
  • + + + + +
    - google.horizonAppId + amazon.fireOS + boolean + Enable the Fire OS Android amazon flavor (see{' '} + Fire OS Setup) +
    + amazon.vegaOS + boolean + Enables Vega OS runtime setup. This prepares Vega manifest and + Kepler project metadata, but it does not select an Android + flavor. Follow the{' '} + Vega OS Runtime guide. +
    + android.horizonAppId string Meta Horizon App ID for Quest/VR devices Google/Android verification parameters.
    + amazon + + RequestVerifyPurchaseWithIapkitAmazonProps? + + Amazon Appstore verification parameters for Fire OS and Vega OS + purchase receipts. +
    @@ -189,6 +201,64 @@ function VerifyPurchaseWithProviderProps() { + + + RequestVerifyPurchaseWithIapkitAmazonProps + +

    + Amazon Appstore receipt verification parameters. Fire OS and Vega OS + both use this amazon payload when verifying through + IAPKit. +

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeSummary
    + userId + + string? + + Amazon Appstore user id returned by{' '} + PurchaseResponse.getUserData().getUserId(). +
    + receiptId + + string + + Amazon Appstore receipt id returned by{' '} + PurchaseResponse.getReceipt().getReceiptId(). +
    + sandbox + + boolean? + + Use Amazon RVS Cloud Sandbox for Amazon App Tester receipts. +
    ); diff --git a/packages/docs/src/pages/docs/updates/announcements.tsx b/packages/docs/src/pages/docs/updates/announcements.tsx index 3007e136..37174703 100644 --- a/packages/docs/src/pages/docs/updates/announcements.tsx +++ b/packages/docs/src/pages/docs/updates/announcements.tsx @@ -52,6 +52,138 @@ function Announcements() { useScrollToHash(); const announcements: Announcement[] = [ + // 2026-06-09: Amazon Fire OS / Vega OS + { + id: '2026-06-09-amazon-fireos-vega', + date: new Date('2026-06-09'), + element: ( +
    + +

    June 9, 2026

    +

    + Today marks a meaningful milestone for OpenIAP. OpenIAP is now + backed by Amazon sponsorship and technical support, and that support + is helping us bring Amazon Fire OS and Vega OS purchase support into + the OpenIAP ecosystem. +

    +

    + Fire OS support starts with in-app purchases through the Android{' '} + amazon flavor and Amazon Appstore SDK. Vega OS support + is a separate React Native for Vega runtime path, with{' '} + react-native-iap runtime support and{' '} + expo-iap config-plugin support for compatible Expo + projects. Together, they help developers keep one familiar OpenIAP + purchase flow while reaching Amazon-specific purchase environments. + IAPKit uses the same Amazon verification path for Fire OS and Vega + OS receipts, so apps can keep managed receipt checks and entitlement + state in the same OpenIAP backend flow. The implementation is + tracked in{' '} + + PR 162 + + . +

    +
      +
    • + Open source sponsorship: Amazon's support helps + sustain OpenIAP's work toward vendor-neutral purchase + interoperability across stores, runtimes, and frameworks. +
    • +
    • + Fire OS setup: Android apps select the Amazon + flavor with fireOsEnabled=true or the matching + framework config-plugin option. +
    • +
    • + Amazon Appstore IAP: OpenIAP maps Amazon + consumables, entitlements, subscriptions, purchase updates, and + fulfillment into the standard OpenIAP API shape. +
    • +
    • + IAPKit verification: Fire OS and Vega OS + purchases can verify through IAPKit's Amazon receipt path with the + same iapkit.amazon payload. +
    • +
    • + Catalog identity: Product IDs stay aligned across + Amazon Appstore, Amazon App Tester, app code, and Kit entitlement + checks. +
    • +
    • + Vega OS runtime: Vega OS is not Fire OS and is + not an Android flavor. The Vega adapter is selected only in the + Kepler runtime for React Native for Vega apps. +
    • +
    + + OpenIAP Amazon Fire OS and Vega OS announcement + +
    + Get started: Read the{' '} + + Fire OS setup guide + {' '} + for Amazon Appstore IAP integration, or open the{' '} + + Vega OS runtime guide + {' '} + for React Native for Vega apps and compatible Expo projects. Use the{' '} + + IAPKit backend guide + {' '} + for Amazon receipt verification and entitlement checks. +
    +
    + ), + }, + // 2026-05-07: maui-iap { id: '2026-05-07', diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 0196cf4b..d95729df 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -207,6 +207,168 @@ function Releases() { ), }, + // May 23, 2026 — Fire OS support + { + id: 'fireos-support-2026-05-23', + date: new Date('2026-05-23'), + element: ( +
    + + May 23, 2026 — Fire OS support + + +

    + Adds Amazon Fire OS support to OpenIAP. Starting with{' '} + openiap-google 2.3.0, Android builds can target Google + Play, Meta Horizon, or Amazon Appstore from the same native package + family. The OpenIAP Spec remains 2.0.3; this + rollout adds the Fire OS runtime flavor, framework build flags, + Amazon Appstore receipt verification paths, and shared subscription + state mapping. +

    + +
      +
    • + Amazon Fire OS flavor — native Android publishes{' '} + openiap-google-amazon, backed by the Amazon Appstore + SDK and selected with the amazon Gradle flavor. +
    • +
    • + Framework rollout — React Native, Expo, Flutter, + Godot, KMP, and MAUI releases include Fire OS metadata and build + integration alongside existing Play and Horizon support. +
    • +
    • + IAPKit verification — Amazon Appstore receipts + can be verified through IAPKit with userId and{' '} + receiptId, while the shared secret remains on the + server. +
    • +
    • + Shared subscription state — Fire OS subscriptions + flow through the same OpenIAP{' '} + + fetchProducts + + ,{' '} + + requestPurchase + + ,{' '} + + getActiveSubscriptions + + , and{' '} + + getAvailablePurchases + {' '} + lifecycle used by the other stores. The Amazon adapter hydrates + product type and subscription group metadata so app and framework + code do not need store-specific receipt alias handling. +
    • +
    • + Setup guide — see{' '} + Fire OS Setup for Amazon App + Tester, public key, and framework flag details. +
    • +
    + +
    +
    Package Releases
    + +
    +
    + ), + }, + // May 19, 2026 — Android Billing callback race hotfix { id: 'android-billing-callback-race-hotfix-2026-05-19', diff --git a/packages/docs/src/styles/pages.css b/packages/docs/src/styles/pages.css index ae0026c3..f51d84ef 100644 --- a/packages/docs/src/styles/pages.css +++ b/packages/docs/src/styles/pages.css @@ -621,6 +621,119 @@ color: #3ddc84; } +/* Example videos */ +.example-video-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + margin: 1rem 0 2rem; +} + +.example-overview-image { + display: block; + width: min(100%, 440px); + margin: 1rem 0 1.25rem; + border: 1px solid var(--border-color); + border-radius: 0.5rem; + background: var(--bg-secondary); +} + +.example-action-layout { + display: grid; + grid-template-columns: 1fr; + gap: 1.25rem; + align-items: start; + margin: 1rem 0 2rem; +} + +.example-action-copy p:first-child { + margin-top: 0; +} + +.example-action-copy { + min-width: 0; +} + +.example-action-copy .doc-table { + display: block; + overflow-x: auto; + margin-top: 1rem; +} + +.example-video-card { + max-width: 440px; + overflow: hidden; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; +} + +.example-video-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-bottom: 1px solid var(--border-color); +} + +.example-video-tab { + appearance: none; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + font-weight: 600; + min-width: 0; + overflow: hidden; + padding: 0.65rem 0.45rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.example-video-tab:hover { + color: var(--text-primary); +} + +.example-video-tab.active { + border-bottom-color: var(--accent-color); + color: var(--text-primary); +} + +.example-video-card video, +.example-video-placeholder { + display: block; + width: 100%; + aspect-ratio: 9 / 16; + max-height: 520px; + background: #111; + border-bottom: 1px solid var(--border-color); +} + +.example-video-placeholder { + display: grid; + place-items: center; + color: var(--text-secondary); + font-size: 0.95rem; +} + +.example-video-content { + padding: 1rem; +} + +.example-video-content h4 { + margin: 0 0 0.4rem; + color: var(--text-primary); + font-size: 1rem; +} + +.example-video-content p { + margin: 0; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.5; +} + @media (max-width: 600px) { .screenshot-card { flex-direction: column; @@ -631,4 +744,8 @@ .screenshot-card img { width: 150px; } + + .example-video-card { + max-width: none; + } } diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts index 83048a50..b4f05c3e 100644 --- a/packages/docs/vite.config.ts +++ b/packages/docs/vite.config.ts @@ -35,6 +35,27 @@ export default defineConfig({ return 'vendor-lucide'; } } + if (id.includes('/src/pages/docs/updates/')) { + return 'docs-updates'; + } + if (id.includes('/src/pages/docs/apis/')) { + return 'docs-apis'; + } + if (id.includes('/src/pages/docs/types/')) { + return 'docs-types'; + } + if (id.includes('/src/pages/docs/features/')) { + return 'docs-features'; + } + if (id.includes('/src/pages/docs/setup/')) { + return 'docs-setup'; + } + if (id.includes('/src/pages/docs/guides/')) { + return 'docs-guides'; + } + if (id.includes('/src/pages/docs/foundation/')) { + return 'docs-foundation'; + } }, }, }, diff --git a/packages/google/CONTRIBUTING.md b/packages/google/CONTRIBUTING.md index e40c02ba..7caa15f9 100644 --- a/packages/google/CONTRIBUTING.md +++ b/packages/google/CONTRIBUTING.md @@ -33,9 +33,18 @@ cd openiap/packages/google adb shell am start -n dev.hyo.martie/.MainActivity ``` -## Horizon OS Development (Meta Quest) +## Store Flavor Development + +This library supports Google Play Store, Meta Horizon OS (Quest devices), and +Fire OS using product flavors. -This library supports both Google Play Store and Meta Horizon OS (Quest devices) using product flavors. +| Variant | Store | +| ------------------------------------- | ------------------------------ | +| **playDebug** / **playRelease** | Google Play Store billing | +| **horizonDebug** / **horizonRelease** | Meta Horizon OS billing | +| **amazonDebug** / **amazonRelease** | Fire OS billing | + +## Horizon OS Development (Meta Quest) ### Setting Up Horizon App ID @@ -65,11 +74,6 @@ EXAMPLE_HORIZON_APP_ID=your_horizon_app_id_here - Set **openiap** module to **horizonDebug** - Run the app (App ID will be read from `local.properties`) -### Build Variants - -- **playDebug** / **playRelease** - Google Play Store billing -- **horizonDebug** / **horizonRelease** - Meta Horizon OS billing - ### Testing on Quest Devices ```bash @@ -85,6 +89,22 @@ adb logcat | grep -E "OpenIap|Horizon" **Note**: The Horizon App ID is required for Horizon Billing to work. Without it, the billing client will fail to connect. +## Fire OS Development + +The Amazon flavor uses the Amazon Appstore SDK and is intended for Fire OS / +Amazon-distributed Android builds. + +```bash +# Build Fire OS variant +./gradlew :Example:assembleAmazonDebug + +# Install Fire OS variant +./gradlew :Example:installAmazonDebug + +# View logs +adb logcat | grep -E "OpenIap|Amazon" +``` + ## Generated Types - All GraphQL models in `openiap/src/main/java/dev/hyo/openiap/Types.kt` are generated from the [`openiap` monorepo](https://github.com/hyodotdev/openiap/tree/main/packages/gql). When you update API behavior, adjust the upstream type generator first so the Kotlin output stays in sync across platforms. diff --git a/packages/google/CONVENTION.md b/packages/google/CONVENTION.md index 017bfbed..b453a3fb 100644 --- a/packages/google/CONVENTION.md +++ b/packages/google/CONVENTION.md @@ -7,6 +7,7 @@ **IMPORTANT**: Since this is an Android-only package, **DO NOT add `Android` suffix** to function names, even for Android-specific APIs. **✅ Correct**: + ```kotlin fun acknowledgePurchase() fun consumePurchase() @@ -16,6 +17,7 @@ fun isHorizonEnvironment(context: Context) ``` **❌ Incorrect**: + ```kotlin fun acknowledgePurchaseAndroid() // Don't add Android suffix fun consumePurchaseAndroid() // Don't add Android suffix @@ -35,6 +37,7 @@ types that contrast with iOS types), or when it is a generated GraphQL operation/handler identifier that must match the schema. ### Enum Values + - Enum values in this codebase must use **kebab-case** (e.g., `non-consumable`, `in-app`, `user-cancelled`) - This matches the convention used in the auto-generated Types.kt from GraphQL schemas - Do not use snake_case (e.g., `non_consumable`) or camelCase for enum raw values @@ -57,33 +60,36 @@ operation/handler identifier that must match the schema. - `OpenIapStore` and other consumers must call the module through these handler properties rather than direct suspend functions, unpacking any wrapper results (such as `RequestPurchaseResultPurchases`) as needed. - Keep helper wiring inside `OpenIapModule`—avoid reintroducing extension builders like `createQueryHandlers`; the module itself owns `queryHandlers`, `mutationHandlers`, and `subscriptionHandlers` values so wiring stays localized and in sync with the typealiases. -## Build Flavors (Play vs Horizon) +## Build Flavors -This package supports **two build flavors**: +This package supports **three build flavors**: -| Flavor | Store | Source Directory | -|--------|-------|------------------| -| `play` (default) | Google Play Store | `src/play/` | -| `horizon` | Meta Quest Store | `src/horizon/` | +| Flavor | Store | Source Directory | +| ---------------- | ---------------------- | ---------------- | +| `play` (default) | Google Play Store | `src/play/` | +| `horizon` | Meta Quest Store | `src/horizon/` | +| `amazon` | Fire OS | `src/amazon/` | ### Source Directory Structure ```text openiap/src/ -├── main/ # Shared code (used by both flavors) +├── main/ # Shared code (used by every flavor) ├── play/ # Play Store specific implementations -└── horizon/ # Meta Horizon specific implementations +├── horizon/ # Meta Horizon specific implementations +└── amazon/ # Fire OS specific implementations ``` ### When to Use Each Directory -- **`src/main/`**: Code that works for BOTH Play and Horizon +- **`src/main/`**: Code that works for Play, Horizon, and Fire OS - **`src/play/`**: Play Store specific code (Google Play Billing API) - **`src/horizon/`**: Horizon specific code (Meta S2S API) +- **`src/amazon/`**: Fire OS specific code (Amazon Appstore SDK) -### Critical: Test Both Flavors +### Critical: Test All Flavors -When modifying shared code in `src/main/`, **ALWAYS test both flavors**: +When modifying shared code in `src/main/`, **ALWAYS test every flavor**: ```bash # Play flavor @@ -91,21 +97,25 @@ When modifying shared code in `src/main/`, **ALWAYS test both flavors**: # Horizon flavor ./gradlew :openiap:compileHorizonDebugKotlin + +# Fire OS flavor +./gradlew :openiap:compileAmazonDebugKotlin ``` -### Horizon-Specific APIs +### Flavor-Specific Helpers -Some APIs exist only in Horizon flavor: +Some implementation helpers exist only on specific Android flavors: -- `getAvailableItems` - Fetch catalog items (Horizon only) +- `getAvailableItems` - Fetch available purchases for Play, Horizon, and Amazon - `VerifyPurchaseHorizonOptions` - Horizon verification parameters - `VerifyPurchaseResultHorizon` - Horizon verification result ## Regeneration Checklist - Run `./scripts/generate-types.sh` whenever GraphQL schema definitions change. -- After regenerating, run the relevant Gradle targets for **BOTH flavors**: +- After regenerating, run the relevant Gradle targets for every flavor: ```bash ./gradlew :openiap:compilePlayDebugKotlin ./gradlew :openiap:compileHorizonDebugKotlin + ./gradlew :openiap:compileAmazonDebugKotlin ``` diff --git a/packages/google/Example/build.gradle.kts b/packages/google/Example/build.gradle.kts index 58964f7f..07c57969 100644 --- a/packages/google/Example/build.gradle.kts +++ b/packages/google/Example/build.gradle.kts @@ -96,6 +96,12 @@ android { ?: "" manifestPlaceholders["OCULUS_APP_ID"] = appId } + + // Amazon flavor - Amazon Appstore SDK IAP + create("amazon") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"amazon\"") + } } buildTypes { diff --git a/packages/google/Example/src/main/AndroidManifest.xml b/packages/google/Example/src/main/AndroidManifest.xml index fcfe5f3e..8ac3a162 100644 --- a/packages/google/Example/src/main/AndroidManifest.xml +++ b/packages/google/Example/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + () } + val accountStoreName = remember { + when (BuildConfig.OPENIAP_STORE) { + "amazon" -> "Amazon Appstore" + "horizon" -> "Meta Horizon" + else -> "Google" + } + } // Modal state var selectedPurchase by remember { mutableStateOf(null) } @@ -177,7 +185,7 @@ fun AvailablePurchasesScreen( } Text( - "View all your active purchases including consumables not yet consumed, non-consumables, and active subscriptions. Tap 'Restore' to recover purchases from your Google account.", + "View all your active purchases including consumables not yet consumed, non-consumables, and active subscriptions. Tap 'Restore' to recover purchases from your $accountStoreName account.", style = MaterialTheme.typography.bodyMedium, color = AppColors.textSecondary ) @@ -381,7 +389,7 @@ fun AvailablePurchasesScreen( if (androidPurchases.isEmpty() && !isInitializing && !status.isLoading) { item { EmptyStateCard( - message = "No purchases found. Try restoring purchases from your Google account.", + message = "No purchases found. Try restoring purchases from your $accountStoreName account.", icon = Icons.Default.ShoppingBag ) } diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt index 905d894d..2ee2dea0 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt @@ -27,23 +27,38 @@ import dev.hyo.martie.screens.uis.FeatureCard fun HomeScreen(navController: NavController) { // Use BuildConfig to determine which billing system is included in this build // This ensures UI messages match the actual compiled implementation - val isHorizonBuild = BuildConfig.OPENIAP_STORE == "horizon" + val storeLabel = when (BuildConfig.OPENIAP_STORE) { + "amazon" -> "Amazon Fire OS" + "horizon" -> "Horizon OS" + else -> "Android" + } - val testText = - if (isHorizonBuild) "Test in-app purchases and subscription features with Meta Horizon Billing integration." - else "Test in-app purchases and subscription features with Google Play Billing integration." + val testText = when (BuildConfig.OPENIAP_STORE) { + "amazon" -> "Test in-app purchases and subscription features with Amazon Appstore IAP integration." + "horizon" -> "Test in-app purchases and subscription features with Meta Horizon Billing integration." + else -> "Test in-app purchases and subscription features with Google Play Billing integration." + } val testingNotesText = - if (isHorizonBuild) { + when (BuildConfig.OPENIAP_STORE) { + "amazon" -> { + "• Use test accounts configured for Amazon Appstore IAP\n" + + "• Products must be configured in Amazon Developer Console\n" + + "• App Tester or an Appstore test distribution is required for live IAP\n" + + "• Device must be signed in with an Amazon test account" + } + "horizon" -> { "• Use test accounts configured in Meta Quest Developer Center\n" + "• Products must be configured in Horizon Store\n" + "• App must be uploaded to Meta Quest Developer Center\n" + "• Device must be signed in with a test account" - } else { + } + else -> { "• Use test accounts configured in Google Play Console\n" + "• Products must be configured in Play Console\n" + "• App must be uploaded to Play Console (at least internal testing)\n" + "• Device must be signed in with a test account" + } } Scaffold { paddingValues -> @@ -88,7 +103,7 @@ fun HomeScreen(navController: NavController) { color = AppColors.secondary.copy(alpha = 0.2f) ) { Text( - "Android", + storeLabel, modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall ) diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt index c904c5d6..06016419 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -23,7 +23,6 @@ import dev.hyo.martie.IapConstants import dev.hyo.martie.models.AppColors import dev.hyo.martie.screens.uis.* import dev.hyo.openiap.IapContext -import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.store.OpenIapStore import dev.hyo.openiap.store.PurchaseResultStatus import kotlinx.coroutines.CoroutineScope @@ -105,6 +104,46 @@ fun PurchaseFlowScreen( runCatching { BuildConfig.IAPKIT_API_KEY.takeIf { it.isNotBlank() } }.getOrNull() } + fun purchasePropsFor(product: ProductAndroid): RequestPurchaseProps = + if (product.type == ProductType.Subs) { + RequestPurchaseProps( + request = RequestPurchaseProps.Request.Subscription( + RequestSubscriptionPropsByPlatforms( + android = RequestSubscriptionAndroidProps( + skus = listOf(product.id) + ) + ) + ), + type = ProductQueryType.Subs + ) + } else { + RequestPurchaseProps( + request = RequestPurchaseProps.Request.Purchase( + RequestPurchasePropsByPlatforms( + android = RequestPurchaseAndroidProps( + skus = listOf(product.id) + ) + ) + ), + type = ProductQueryType.InApp + ) + } + + fun launchPurchase(product: ProductAndroid) { + uiScope.launch { + iapStore.setActivity(activity) + try { + iapStore.requestPurchase(purchasePropsFor(product)) + } catch (e: Exception) { + iapStore.postStatusMessage( + message = e.message ?: "Purchase failed", + status = PurchaseResultStatus.Error, + productId = product.id + ) + } + } + } + // Use a dedicated scope for cleanup that won't be cancelled with composition val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) } @@ -448,36 +487,7 @@ fun PurchaseFlowScreen( ProductCard( product = androidProduct, isPurchasing = status.isPurchasing(androidProduct.id), - onPurchase = { - scope.launch { - iapStore.setActivity(activity) - if (androidProduct.type == ProductType.Subs) { - val props = RequestPurchaseProps( - request = RequestPurchaseProps.Request.Subscription( - RequestSubscriptionPropsByPlatforms( - android = RequestSubscriptionAndroidProps( - skus = listOf(androidProduct.id) - ) - ) - ), - type = ProductQueryType.Subs - ) - iapStore.requestPurchase(props) - } else { - val props = RequestPurchaseProps( - request = RequestPurchaseProps.Request.Purchase( - RequestPurchasePropsByPlatforms( - android = RequestPurchaseAndroidProps( - skus = listOf(androidProduct.id) - ) - ) - ), - type = ProductQueryType.InApp - ) - iapStore.requestPurchase(props) - } - } - }, + onPurchase = { launchPurchase(androidProduct) }, onClick = { selectedProduct = androidProduct }, @@ -724,36 +734,7 @@ fun PurchaseFlowScreen( ProductDetailModal( product = product, onDismiss = { selectedProduct = null }, - onPurchase = { - uiScope.launch { - iapStore.setActivity(activity) - if (product.type == ProductType.Subs) { - val props = RequestPurchaseProps( - request = RequestPurchaseProps.Request.Subscription( - RequestSubscriptionPropsByPlatforms( - android = RequestSubscriptionAndroidProps( - skus = listOf(product.id) - ) - ) - ), - type = ProductQueryType.Subs - ) - iapStore.requestPurchase(props) - } else { - val props = RequestPurchaseProps( - request = RequestPurchaseProps.Request.Purchase( - RequestPurchasePropsByPlatforms( - android = RequestPurchaseAndroidProps( - skus = listOf(product.id) - ) - ) - ), - type = ProductQueryType.InApp - ) - iapStore.requestPurchase(props) - } - } - }, + onPurchase = { launchPurchase(product) }, isPurchasing = status.isPurchasing(product.id) ) } diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index ba1b36ff..8a0d0072 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -1,6 +1,19 @@ import groovy.json.JsonSlurper +import java.io.File import org.jetbrains.kotlin.gradle.dsl.JvmTarget +fun locateOpeniapVersionsFile(startDir: File): File { + var current: File? = startDir + while (current != null) { + val candidate = File(current, "openiap-versions.json") + if (candidate.isFile) { + return candidate + } + current = current.parentFile + } + throw GradleException("packages/google: missing openiap-versions.json from ${startDir.absolutePath}") +} + plugins { id("com.android.library") id("org.jetbrains.kotlin.android") @@ -8,14 +21,18 @@ plugins { id("com.vanniktech.maven.publish") } -// Read version from monorepo root openiap-versions.json -val versionsFile = File(rootDir.parentFile.parentFile, "openiap-versions.json") -if (!versionsFile.isFile) { - error("packages/google: missing openiap-versions.json at ${versionsFile.path}") -} +// Read version from Gradle property first, then from monorepo root openiap-versions.json. +// Release and local publish scripts pass -P/ORG_GRADLE_PROJECT_openIapVersion; +// normal development builds use the repository SSOT file. +val versionsFile = locateOpeniapVersionsFile(projectDir) val versionsJson = JsonSlurper().parseText(versionsFile.readText()) as Map<*, *> -val openIapVersion: String = versionsJson["google"]?.toString() - ?: error("packages/google: 'google' version missing in openiap-versions.json") +val openIapVersion: String = project.findProperty("openIapVersion")?.toString()?.takeIf { it.isNotBlank() } + ?: versionsJson["google"]?.toString()?.takeIf { it.isNotBlank() } + ?: throw GradleException("packages/google: 'google' version missing in openiap-versions.json") +val isPublishTaskRequested = gradle.startParameter.taskNames.any { taskName -> + taskName.contains("publish", ignoreCase = true) || + taskName.contains("mavenCentral", ignoreCase = true) +} android { namespace = "io.github.hyochan.openiap" @@ -51,6 +68,11 @@ android { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"horizon\"") } + // Amazon flavor - Amazon Appstore SDK IAP only + create("amazon") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"amazon\"") + } } compileOptions { @@ -75,12 +97,19 @@ android { named("horizon") { java.srcDirs("src/horizon/java") } + named("amazon") { + java.srcDirs("src/amazon/java") + manifest.srcFile("src/amazon/AndroidManifest.xml") + } named("testPlay") { java.srcDirs("src/testPlay/java") } named("testHorizon") { java.srcDirs("src/testHorizon/java") } + named("testAmazon") { + java.srcDirs("src/testAmazon/java") + } } testOptions { @@ -120,6 +149,10 @@ dependencies { add("horizonCompileOnly", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:$horizonBillingCompatibilityVersion") add("horizonApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:$horizonBillingCompatibilityVersion") + // Amazon flavor: Amazon Appstore SDK for Fire OS IAP + add("amazonCompileOnly", "com.amazon.device:amazon-appstore-sdk:3.0.8") + add("amazonApi", "com.amazon.device:amazon-appstore-sdk:3.0.8") + // Kotlin Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion") @@ -170,6 +203,22 @@ mavenPublishing { url.set("https://github.com/hyodotdev/openiap") } } + "amazon" -> { + coordinates(groupId, "openiap-google-amazon", openIapVersion) + + // Publish the Amazon flavor (Amazon Appstore SDK) + configure(com.vanniktech.maven.publish.AndroidSingleVariantLibrary( + variant = "amazonRelease", + sourcesJar = true, + publishJavadocJar = true + )) + + pom { + name.set("OpenIAP Amazon") + description.set("OpenIAP Android library using Amazon Appstore SDK IAP") + url.set("https://github.com/hyodotdev/openiap") + } + } else -> { // "play" is default coordinates(groupId, "openiap-google", openIapVersion) @@ -188,9 +237,11 @@ mavenPublishing { } } - // Central Portal is the default Maven Central target on Vanniktech 0.33+. - publishToMavenCentral() - signAllPublications() + if (isPublishTaskRequested) { + // Use the Central Portal publishing path only for publishing tasks. + publishToMavenCentral() + signAllPublications() + } pom { licenses { diff --git a/packages/google/openiap/src/amazon/AndroidManifest.xml b/packages/google/openiap/src/amazon/AndroidManifest.xml new file mode 100644 index 00000000..9dacfe9d --- /dev/null +++ b/packages/google/openiap/src/amazon/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapErrorExtensions.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapErrorExtensions.kt new file mode 100644 index 00000000..2aa9923f --- /dev/null +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapErrorExtensions.kt @@ -0,0 +1,26 @@ +package dev.hyo.openiap + +/** + * Compatibility mapping for tests and Android callers that still pass Google + * Billing-style response codes while using the Amazon flavor. + */ +@Suppress("DEPRECATION") +fun OpenIapError.Companion.fromBillingResponseCode( + responseCode: Int, + debugMessage: String? = null, +): OpenIapError { + return when (responseCode) { + 1 -> OpenIapError.UserCancelled(debugMessage) + 2 -> OpenIapError.ServiceUnavailable(debugMessage) + 3 -> OpenIapError.BillingUnavailable(debugMessage) + 4 -> OpenIapError.ItemUnavailable(debugMessage) + 5 -> OpenIapError.DeveloperError(debugMessage) + 6 -> OpenIapError.BillingError(debugMessage) + 7 -> OpenIapError.ItemAlreadyOwned(debugMessage) + 8 -> OpenIapError.ItemNotOwned(debugMessage) + -1 -> OpenIapError.ServiceDisconnected(debugMessage) + -2 -> OpenIapError.FeatureNotSupported(debugMessage) + -3 -> OpenIapError.ServiceTimeout(debugMessage) + else -> OpenIapError.UnknownError(debugMessage) + } +} diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt new file mode 100644 index 00000000..1bc97472 --- /dev/null +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt @@ -0,0 +1,1056 @@ +package dev.hyo.openiap + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.amazon.device.iap.PurchasingListener +import com.amazon.device.iap.PurchasingService +import com.amazon.device.iap.model.FulfillmentResult +import com.amazon.device.iap.model.ProductDataResponse +import com.amazon.device.iap.model.PurchaseResponse +import com.amazon.device.iap.model.PurchaseUpdatesResponse +import com.amazon.device.iap.model.UserData +import com.amazon.device.iap.model.UserDataResponse +import dev.hyo.openiap.helpers.onPurchaseError +import dev.hyo.openiap.helpers.onPurchaseUpdated +import dev.hyo.openiap.helpers.toAndroidPurchaseArgs +import dev.hyo.openiap.listener.DeveloperProvidedBillingListener +import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener +import dev.hyo.openiap.listener.UserChoiceBillingListener +import dev.hyo.openiap.utils.verifyPurchaseWithIapkit +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.lang.ref.WeakReference +import java.text.NumberFormat +import java.text.ParsePosition +import java.util.Currency +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import com.amazon.device.iap.model.Product as AmazonProduct +import com.amazon.device.iap.model.ProductType as AmazonProductType +import com.amazon.device.iap.model.Receipt as AmazonReceipt + +private const val TAG = "OpenIapAmazon" +private const val AMAZON_REQUEST_TIMEOUT_MS = 60_000L +private const val AMAZON_PRODUCT_DATA_BATCH_SIZE = 100 +private const val AMAZON_PURCHASE_UPDATES_MAX_PAGES = 100 + +internal object AmazonPriceParser { + fun toPriceAmount(displayPrice: String?): Double { + val value = displayPrice?.trim().orEmpty() + if (value.isEmpty()) return 0.0 + + parseLocalizedPrice(value)?.let { return it } + + val numeric = value.replace(Regex("[^0-9,.-]"), "") + if (numeric.isBlank()) return 0.0 + + val lastDot = numeric.lastIndexOf('.') + val lastComma = numeric.lastIndexOf(',') + val decimalIndex = maxOf(lastDot, lastComma) + val hasMixedSeparators = lastDot >= 0 && lastComma >= 0 + val fractionLength = if (decimalIndex >= 0) { + numeric.length - decimalIndex - 1 + } else { + 0 + } + val currencyFractionDigits = value.currencyFractionDigits() + val separatorMatchesCurrency = currencyFractionDigits != null && + fractionLength == currencyFractionDigits + val hasDecimalSeparator = decimalIndex >= 0 && + (hasMixedSeparators || fractionLength in 1..2 || separatorMatchesCurrency) + val normalized = if (hasDecimalSeparator) { + val integerPart = numeric.substring(0, decimalIndex).replace(Regex("[^0-9-]"), "") + val fractionPart = numeric.substring(decimalIndex + 1).replace(Regex("[^0-9]"), "") + "$integerPart.$fractionPart" + } else { + numeric.replace(Regex("[^0-9-]"), "") + } + return normalized.toDoubleOrNull() ?: 0.0 + } + + private fun parseLocalizedPrice(value: String): Double? { + val locale = Locale.getDefault() + return listOf( + NumberFormat.getCurrencyInstance(locale), + NumberFormat.getNumberInstance(locale) + ).firstNotNullOfOrNull { format -> + val position = ParsePosition(0) + val parsed = format.parse(value, position) + if ( + parsed != null && + position.index > 0 && + !value.hasUnparsedPriceCharacters(position.index) + ) { + parsed.toDouble() + } else { + null + } + } + } + + private fun String.hasUnparsedPriceCharacters(startIndex: Int): Boolean { + return drop(startIndex).any { it.isDigit() || it == '.' || it == ',' || it == '-' } + } + + private fun String.currencyFractionDigits(): Int? { + val code = Regex("\\b[A-Z]{3}\\b").find(this)?.value ?: return null + return runCatching { Currency.getInstance(code).defaultFractionDigits } + .getOrNull() + ?.takeIf { it >= 0 } + } +} + +internal fun buildAmazonPurchase( + packageName: String, + receiptId: String, + receiptSku: String, + isSubscription: Boolean, + purchaseDateMillis: Double, + isCanceled: Boolean, + isDeferred: Boolean, + productIdOverride: String? = null +): PurchaseAndroid { + val resolvedProductId = productIdOverride?.takeIf { it.isNotBlank() } ?: receiptSku + val state = when { + isDeferred -> PurchaseState.Pending + isCanceled -> PurchaseState.Unknown + else -> PurchaseState.Purchased + } + return PurchaseAndroid( + autoRenewingAndroid = isSubscription && !isCanceled && !isDeferred, + currentPlanId = if (isSubscription) resolvedProductId else null, + dataAndroid = "", + id = receiptId, + ids = listOf(resolvedProductId), + isAcknowledgedAndroid = null, + isAutoRenewing = isSubscription && !isCanceled && !isDeferred, + packageNameAndroid = packageName, + platform = IapPlatform.Android, + productId = resolvedProductId, + purchaseState = state, + purchaseToken = receiptId, + quantity = 1, + signatureAndroid = null, + store = IapStore.Amazon, + transactionDate = purchaseDateMillis, + transactionId = receiptId, + isSuspendedAndroid = isDeferred + ) +} + +/** + * OpenIapModule for Amazon Appstore SDK IAP. + * + * Amazon's native IAP API is listener based instead of connection based. The + * OpenIAP connection lifecycle registers the listener, while individual API + * calls await the matching RequestId callback. + */ +class OpenIapModule( + private val context: Context, + @Suppress("UNUSED_PARAMETER") + private var alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE, + @Suppress("UNUSED_PARAMETER") + private var userChoiceBillingListener: UserChoiceBillingListener? = null, + @Suppress("UNUSED_PARAMETER") + private var developerProvidedBillingListener: DeveloperProvidedBillingListener? = null +) : OpenIapProtocol, PurchasingListener { + + constructor(context: Context, enableAlternativeBilling: Boolean) : this( + context, + if (enableAlternativeBilling) AlternativeBillingMode.ALTERNATIVE_ONLY else AlternativeBillingMode.NONE, + null + ) + + private var currentActivityRef: WeakReference? = null + private var isRegistered = false + private var storefrontCode: String = "" + + private val productDataRequests = ConcurrentHashMap>() + private val purchaseRequests = ConcurrentHashMap>() + private val purchaseUpdatesRequests = ConcurrentHashMap>() + private val userDataRequests = ConcurrentHashMap>() + private val earlyProductDataResponses = ConcurrentHashMap() + private val earlyPurchaseResponses = ConcurrentHashMap() + private val earlyPurchaseUpdatesResponses = ConcurrentHashMap() + private val earlyUserDataResponses = ConcurrentHashMap() + private val timedOutRequestIds = ConcurrentHashMap.newKeySet() + private val purchaseTypeByReceiptId = ConcurrentHashMap() + private val purchaseSkuByReceiptId = ConcurrentHashMap() + private val productTypeBySku = ConcurrentHashMap() + + private val purchaseUpdateListeners = ConcurrentHashMap.newKeySet() + private val purchaseErrorListeners = ConcurrentHashMap.newKeySet() + + private fun ensureRegistered() { + if (isRegistered) return + PurchasingService.registerListener(context.applicationContext, this) + isRegistered = true + } + + override fun setActivity(activity: Activity?) { + currentActivityRef = activity?.let { WeakReference(it) } + } + + override val initConnection: MutationInitConnectionHandler = { + withContext(Dispatchers.Main) { + runCatching { + ensureRegistered() + val response = requestUserData() + when (response.requestStatus) { + UserDataResponse.RequestStatus.SUCCESSFUL -> true + UserDataResponse.RequestStatus.NOT_SUPPORTED -> { + OpenIapLog.w("Amazon initConnection not supported on this device", TAG) + false + } + UserDataResponse.RequestStatus.FAILED -> { + OpenIapLog.w("Amazon initConnection user data request failed", TAG) + false + } + } + }.getOrElse { error -> + OpenIapLog.e("Amazon initConnection failed: ${error.message}", error, TAG) + false + } + } + } + + override val endConnection: MutationEndConnectionHandler = { + withContext(Dispatchers.IO) { + productDataRequests.clear() + purchaseRequests.clear() + purchaseUpdatesRequests.clear() + userDataRequests.clear() + earlyProductDataResponses.clear() + earlyPurchaseResponses.clear() + earlyPurchaseUpdatesResponses.clear() + earlyUserDataResponses.clear() + timedOutRequestIds.clear() + purchaseTypeByReceiptId.clear() + purchaseSkuByReceiptId.clear() + productTypeBySku.clear() + storefrontCode = "" + true + } + } + + override val fetchProducts: QueryFetchProductsHandler = { params -> + withContext(Dispatchers.IO) { + val queryType = params.type ?: ProductQueryType.All + if (params.skus.isEmpty() && queryType != ProductQueryType.All) { + throw OpenIapError.EmptySkuList + } + + val responses = params.skus + .chunked(AMAZON_PRODUCT_DATA_BATCH_SIZE) + .let { batches -> + coroutineScope { + batches.map { batch -> + async { requestProductData(batch) } + }.awaitAll() + } + } + val failedResponse = responses.firstOrNull { + it.requestStatus != ProductDataResponse.RequestStatus.SUCCESSFUL + } + + when (failedResponse?.requestStatus ?: ProductDataResponse.RequestStatus.SUCCESSFUL) { + ProductDataResponse.RequestStatus.SUCCESSFUL -> { + val products = responses.flatMap { response -> + response.productData.orEmpty().values + } + .sortedWith(compareBy { product -> + params.skus.indexOf(product.sku).takeIf { it >= 0 } ?: Int.MAX_VALUE + }) + products.forEach(::cacheProductType) + + val inApps = products + .filter { it.productType != AmazonProductType.SUBSCRIPTION } + .map { it.toInAppProduct() } + val subscriptions = products + .filter { it.productType == AmazonProductType.SUBSCRIPTION } + .map { it.toSubscriptionProduct() } + + when (queryType) { + ProductQueryType.InApp -> FetchProductsResultProducts(inApps) + ProductQueryType.Subs -> FetchProductsResultSubscriptions(subscriptions) + ProductQueryType.All -> { + val allItems = buildList { + inApps.forEach { add(ProductOrSubscription.ProductItem(it)) } + subscriptions.forEach { add(ProductOrSubscription.ProductSubscriptionItem(it)) } + } + FetchProductsResultAll(allItems) + } + } + } + ProductDataResponse.RequestStatus.NOT_SUPPORTED -> { + throw OpenIapError.FeatureNotSupported("Amazon Appstore IAP is not supported on this device") + } + ProductDataResponse.RequestStatus.FAILED -> { + throw OpenIapError.QueryProduct.withDiagnostics( + debugMessage = "Amazon getProductData failed", + productIds = params.skus, + productType = queryType.rawValue, + isEmptyProductList = responses.all { it.productData.isNullOrEmpty() } + ) + } + } + } + } + + override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { options -> + withContext(Dispatchers.IO) { + val purchases = getAvailableItems(ProductQueryType.All) + if (options?.includeSuspendedAndroid == true) { + purchases + } else { + purchases.filterNot { (it as? PurchaseAndroid)?.isSuspendedAndroid == true } + } + } + } + + override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds -> + withContext(Dispatchers.IO) { + val ids = subscriptionIds.orEmpty().toSet() + getAvailablePurchases(null) + .filterIsInstance() + .filter { purchase -> + purchase.isAutoRenewing && (ids.isEmpty() || purchase.productId in ids) + } + .map { purchase -> + ActiveSubscription( + autoRenewingAndroid = purchase.autoRenewingAndroid, + basePlanIdAndroid = purchase.currentPlanId, + currentPlanId = purchase.currentPlanId, + isActive = purchase.purchaseState == PurchaseState.Purchased, + productId = purchase.productId, + purchaseToken = purchase.purchaseToken, + purchaseTokenAndroid = purchase.purchaseToken, + transactionDate = purchase.transactionDate, + transactionId = purchase.transactionId ?: purchase.id + ) + } + } + } + + override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> + getActiveSubscriptions(subscriptionIds).isNotEmpty() + } + + override val requestPurchase: MutationRequestPurchaseHandler = { props -> + val purchases = withContext(Dispatchers.IO) { + ensureRegistered() + val androidArgs = props.toAndroidPurchaseArgs() + if (androidArgs.skus.isEmpty()) { + emitPurchaseErrorAndThrow(OpenIapError.EmptySkuList) + } + if (androidArgs.skus.size != 1) { + emitPurchaseErrorAndThrow( + OpenIapError.DeveloperError("Amazon Appstore SDK purchases one SKU at a time") + ) + } + + val sku = androidArgs.skus.first() + val response = runCatching { requestAmazonPurchase(sku) } + .getOrElse { error -> + emitPurchaseErrorAndThrow( + error.toOpenIapError("Amazon purchase request failed") + ) + } + when (response.requestStatus) { + PurchaseResponse.RequestStatus.SUCCESSFUL -> { + val receipt = response.receipt ?: run { + emitPurchaseErrorAndThrow( + OpenIapError.PurchaseFailed("Amazon purchase response did not include a receipt") + ) + } + if (!receipt.sku.isNullOrBlank() && receipt.sku != sku) { + OpenIapLog.w( + "Amazon receipt SKU '${receipt.sku}' differs from requested SKU '$sku'. " + + "Using the requested SKU for the OpenIAP purchase productId; " + + "align the Amazon catalog and App Tester data for restore and server verification.", + TAG + ) + } + // This response is correlated by Amazon requestId, so the + // local request SKU is safe even when callbacks arrive out + // of order. + cacheReceiptProduct(receipt, receipt.productTypeOrNull(), sku) + val purchase = receipt.toPurchase( + productTypeOverride = receipt.productTypeOrNull(), + productIdOverride = sku + ) + purchaseUpdateListeners.forEach { listener -> + runCatching { listener.onPurchaseUpdated(purchase) } + } + listOf(purchase) + } + PurchaseResponse.RequestStatus.ALREADY_PURCHASED -> { + val error = OpenIapError.ItemAlreadyOwned("Amazon reported the item is already purchased") + emitPurchaseErrorAndThrow(error) + } + PurchaseResponse.RequestStatus.INVALID_SKU -> { + val error = OpenIapError.SkuNotFound(sku) + emitPurchaseErrorAndThrow(error) + } + PurchaseResponse.RequestStatus.NOT_SUPPORTED -> { + val error = OpenIapError.FeatureNotSupported("Amazon Appstore IAP is not supported on this device") + emitPurchaseErrorAndThrow(error) + } + PurchaseResponse.RequestStatus.PENDING -> { + val error = OpenIapError.PurchaseDeferred + emitPurchaseErrorAndThrow(error) + } + PurchaseResponse.RequestStatus.FAILED -> { + val error = OpenIapError.UserCancelled("Amazon purchase failed or was cancelled") + emitPurchaseErrorAndThrow(error) + } + } + } + RequestPurchaseResultPurchases(purchases) + } + + suspend fun getAvailableItems(type: ProductQueryType): List = withContext(Dispatchers.IO) { + requestPurchaseUpdates(reset = true).filter { purchase -> + val receiptId = purchase.purchaseToken ?: purchase.id + val productType = purchaseTypeByReceiptId[receiptId] + ?: productTypeBySku[purchase.productId] + when (type) { + ProductQueryType.All -> true + ProductQueryType.Subs -> productType == AmazonProductType.SUBSCRIPTION + ProductQueryType.InApp -> productType != AmazonProductType.SUBSCRIPTION + } + } + } + + override val finishTransaction: MutationFinishTransactionHandler = { purchase, _ -> + withContext(Dispatchers.IO) { + ensureRegistered() + val receiptId = purchase.purchaseToken ?: purchase.id + if (receiptId.isBlank()) { + throw OpenIapError.PurchaseFailed("Missing Amazon receiptId") + } + PurchasingService.notifyFulfillment(receiptId, FulfillmentResult.FULFILLED) + } + } + + override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken -> + acknowledgePurchase(purchaseToken) + } + + override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken -> + consumePurchase(purchaseToken) + } + + override val restorePurchases: MutationRestorePurchasesHandler = { + withContext(Dispatchers.IO) { + requestPurchaseUpdates(reset = true) + Unit + } + } + + override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { + withContext(Dispatchers.Main) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("amzn://apps/android?p=${context.packageName}")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) } + .onFailure { OpenIapLog.w("Amazon subscription deep link unavailable: ${it.message}", TAG) } + Unit + } + } + + @Deprecated("Use verifyPurchase") + override val validateReceipt: MutationValidateReceiptHandler = { + verifyPurchase(it) + } + + override val verifyPurchase: MutationVerifyPurchaseHandler = { + throw OpenIapError.FeatureNotSupported( + "Amazon receipt verification requires server-side RVS integration" + ) + } + + override val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler = { + if (it.provider != PurchaseVerificationProvider.Iapkit) { + throw OpenIapError.FeatureNotSupported() + } + val options = it.iapkit ?: throw OpenIapError.DeveloperError( + "Missing IAPKit verification parameters" + ) + val payloadCount = listOfNotNull(options.apple, options.google, options.amazon).size + val amazon = options.amazon + if (payloadCount != 1 || amazon == null) { + throw OpenIapError.DeveloperError( + "Amazon IAPKit verification requires exactly one amazon payload" + ) + } + val resolvedOptions = if (amazon.userId.isNullOrBlank()) { + val userDataResponse = requestUserData() + val userId = userDataResponse.userData?.userId + ?: throw OpenIapError.DeveloperError("Amazon IAPKit verification could not resolve userId") + options.copy(amazon = amazon.copy(userId = userId)) + } else { + options + } + + VerifyPurchaseWithProviderResult( + iapkit = verifyPurchaseWithIapkit(resolvedOptions, TAG), + provider = it.provider + ) + } + + private val purchaseError: SubscriptionPurchaseErrorHandler = { + onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) + } + + private val purchaseUpdated: SubscriptionPurchaseUpdatedHandler = { + onPurchaseUpdated(this::addPurchaseUpdateListener, this::removePurchaseUpdateListener) + } + + private val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler = { + // Amazon Appstore SDK does not expose a suspended-subscription event, so + // fail immediately instead of leaving consumers waiting forever. + throw OpenIapError.FeatureNotSupported() + } + + override val queryHandlers: QueryHandlers = QueryHandlers( + fetchProducts = fetchProducts, + getActiveSubscriptions = getActiveSubscriptions, + getAvailablePurchases = getAvailablePurchases, + getStorefront = { getStorefront() }, + getStorefrontIOS = { getStorefront() }, + hasActiveSubscriptions = hasActiveSubscriptions + ) + + @Suppress("DEPRECATION") + override val mutationHandlers: MutationHandlers = MutationHandlers( + acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, + checkAlternativeBillingAvailabilityAndroid = { checkAlternativeBillingAvailability() }, + consumePurchaseAndroid = consumePurchaseAndroid, + createAlternativeBillingTokenAndroid = { createAlternativeBillingReportingToken() }, + createBillingProgramReportingDetailsAndroid = { program -> + createBillingProgramReportingDetails(program) + }, + deepLinkToSubscriptions = deepLinkToSubscriptions, + endConnection = endConnection, + finishTransaction = finishTransaction, + initConnection = initConnection, + isBillingProgramAvailableAndroid = { program -> isBillingProgramAvailable(program) }, + launchExternalLinkAndroid = { params -> + val activity = currentActivityRef?.get() + ?: throw OpenIapError.MissingCurrentActivity + launchExternalLink(activity, params) + }, + requestPurchase = requestPurchase, + restorePurchases = restorePurchases, + showAlternativeBillingDialogAndroid = { + val activity = currentActivityRef?.get() + ?: throw OpenIapError.MissingCurrentActivity + showAlternativeBillingInformationDialog(activity) + }, + validateReceipt = validateReceipt, + verifyPurchase = verifyPurchase, + verifyPurchaseWithProvider = verifyPurchaseWithProvider + ) + + override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( + purchaseError = purchaseError, + purchaseUpdated = purchaseUpdated, + subscriptionBillingIssue = subscriptionBillingIssue + ) + + suspend fun getStorefront(): String = withContext(Dispatchers.IO) { + if (storefrontCode.isNotBlank()) return@withContext storefrontCode + runCatching { requestUserData() } + storefrontCode + } + + override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + purchaseUpdateListeners.add(listener) + } + + override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + purchaseUpdateListeners.remove(listener) + } + + override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + purchaseErrorListeners.add(listener) + } + + override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + purchaseErrorListeners.remove(listener) + } + + override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) = Unit + + override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) = Unit + + override fun addDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) = Unit + + override fun removeDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) = Unit + + override fun addSubscriptionBillingIssueListener(listener: OpenIapSubscriptionBillingIssueListener) = Unit + + override fun removeSubscriptionBillingIssueListener(listener: OpenIapSubscriptionBillingIssueListener) = Unit + + override fun setUserChoiceBillingListener(listener: UserChoiceBillingListener?) { + userChoiceBillingListener = listener + } + + override fun setDeveloperProvidedBillingListener(listener: DeveloperProvidedBillingListener?) { + developerProvidedBillingListener = listener + } + + @Deprecated("Amazon Appstore does not support Google Play alternative billing") + override suspend fun checkAlternativeBillingAvailability(): Boolean = false + + @Deprecated("Amazon Appstore does not support Google Play alternative billing") + override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = false + + @Deprecated("Amazon Appstore does not support Google Play alternative billing") + override suspend fun createAlternativeBillingReportingToken(): String? = null + + override suspend fun isBillingProgramAvailable( + program: BillingProgramAndroid + ): BillingProgramAvailabilityResultAndroid = BillingProgramAvailabilityResultAndroid( + billingProgram = program, + isAvailable = false + ) + + override suspend fun createBillingProgramReportingDetails( + program: BillingProgramAndroid + ): BillingProgramReportingDetailsAndroid { + throw OpenIapError.FeatureNotSupported("Amazon Appstore does not support Google Play billing programs") + } + + override suspend fun launchExternalLink( + activity: Activity, + params: LaunchExternalLinkParamsAndroid + ): Boolean = false + + override fun onUserDataResponse(userDataResponse: UserDataResponse) { + updateStorefront(userDataResponse.userData) + completeOrCache( + userDataRequests, + earlyUserDataResponses, + userDataResponse.requestId.toString(), + userDataResponse + ) + } + + override fun onProductDataResponse(productDataResponse: ProductDataResponse) { + completeOrCache( + productDataRequests, + earlyProductDataResponses, + productDataResponse.requestId.toString(), + productDataResponse + ) + } + + override fun onPurchaseResponse(purchaseResponse: PurchaseResponse) { + completeOrCache( + purchaseRequests, + earlyPurchaseResponses, + purchaseResponse.requestId.toString(), + purchaseResponse + ) + } + + override fun onPurchaseUpdatesResponse(purchaseUpdatesResponse: PurchaseUpdatesResponse) { + purchaseUpdatesResponse.receipts.orEmpty().forEach { receipt -> + cacheReceiptProduct(receipt, receipt.productTypeOrNull()) + } + completeOrCache( + purchaseUpdatesRequests, + earlyPurchaseUpdatesResponses, + purchaseUpdatesResponse.requestId.toString(), + purchaseUpdatesResponse + ) + } + + private fun emitPurchaseError(error: OpenIapError) { + purchaseErrorListeners.forEach { listener -> + runCatching { listener.onPurchaseError(error) } + } + } + + private fun emitPurchaseErrorAndThrow(error: OpenIapError): Nothing { + emitPurchaseError(error) + throw error + } + + private suspend fun requestUserData(): UserDataResponse { + val requestId = withContext(Dispatchers.Main) { + ensureRegistered() + val request = runCatching { PurchasingService.getUserData() } + .getOrElse { + throw OpenIapError.InitConnection + } + ?: throw OpenIapError.InitConnection + request.toString() + } + return awaitAmazonResponse(requestId, userDataRequests, earlyUserDataResponses) + } + + private suspend fun requestProductData(skus: List): ProductDataResponse { + val requestId = withContext(Dispatchers.Main) { + ensureRegistered() + val request = runCatching { + PurchasingService.getProductData(skus.toSet()) + } + .getOrElse { error -> + throw error.toOpenIapError("Amazon getProductData request failed") + } + ?: throw OpenIapError.QueryProduct.withDiagnostics( + debugMessage = "Amazon getProductData failed to return a requestId", + productIds = skus + ) + request.toString() + } + return awaitAmazonResponse(requestId, productDataRequests, earlyProductDataResponses) + } + + private suspend fun requestAmazonPurchase(sku: String): PurchaseResponse { + val requestId = withContext(Dispatchers.Main) { + ensureRegistered() + val request = runCatching { PurchasingService.purchase(sku) } + .getOrElse { error -> + throw error.toOpenIapError("Amazon purchase request failed") + } + if (request == null) { + throw OpenIapError.PurchaseFailed("Amazon purchase request did not return a requestId") + } + request.toString() + } + return awaitAmazonResponse(requestId, purchaseRequests, earlyPurchaseResponses) + } + + private suspend fun requestPurchaseUpdates(reset: Boolean): List { + val receipts = mutableListOf() + var shouldReset = reset + var pageCount = 0 + var hasMore = false + do { + if (pageCount >= AMAZON_PURCHASE_UPDATES_MAX_PAGES) { + throw OpenIapError.ServiceTimeout( + "Amazon purchase updates exceeded pagination limit ($AMAZON_PURCHASE_UPDATES_MAX_PAGES pages)" + ) + } + pageCount += 1 + val response = awaitPurchaseUpdates(shouldReset) + shouldReset = false + when (response.requestStatus) { + PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL -> { + receipts += response.receipts.orEmpty() + .filter { it.cancelDate == null } + } + PurchaseUpdatesResponse.RequestStatus.NOT_SUPPORTED -> { + throw OpenIapError.FeatureNotSupported("Amazon Appstore IAP is not supported on this device") + } + PurchaseUpdatesResponse.RequestStatus.FAILED -> { + throw OpenIapError.RestoreFailed + } + } + hasMore = response.hasMore() + } while (hasMore) + hydrateProductTypesForReceipts(receipts) + return receipts.map { receipt -> + val productType = receipt.productTypeOrNull() + ?: productTypeBySku[receipt.sku.orEmpty()] + cacheReceiptProduct(receipt, productType) + receipt.toPurchase( + productTypeOverride = productType, + productIdOverride = canonicalSkuForReceipt(receipt) + ) + } + } + + private suspend fun hydrateProductTypesForReceipts(receipts: List) { + val missingSkus = linkedSetOf() + receipts.forEach { receipt -> + val sku = receipt.sku.orEmpty() + if (sku.isBlank()) return@forEach + + val productType = receipt.productTypeOrNull() + if (productType != null) { + cacheReceiptProduct(receipt, productType) + } else if (!productTypeBySku.containsKey(sku)) { + missingSkus += sku + } + } + if (missingSkus.isEmpty()) return + + missingSkus.chunked(AMAZON_PRODUCT_DATA_BATCH_SIZE).forEach { batch -> + val response = requestProductData(batch) + when (response.requestStatus) { + ProductDataResponse.RequestStatus.SUCCESSFUL -> { + response.productData.orEmpty().values.forEach { product -> + cacheProductType(product) + } + } + ProductDataResponse.RequestStatus.NOT_SUPPORTED -> { + throw OpenIapError.FeatureNotSupported("Amazon Appstore IAP is not supported on this device") + } + ProductDataResponse.RequestStatus.FAILED -> { + throw OpenIapError.QueryProduct.withDiagnostics( + debugMessage = "Amazon getProductData failed while hydrating purchase types", + productIds = batch, + productType = ProductQueryType.All.rawValue, + isEmptyProductList = response.productData.isNullOrEmpty() + ) + } + } + } + } + + private fun cacheReceiptProduct( + receipt: AmazonReceipt, + productType: AmazonProductType?, + requestedSku: String? = null + ) { + val receiptId = receipt.receiptId.orEmpty() + val sku = receipt.sku.orEmpty() + if (receiptId.isNotBlank() && !requestedSku.isNullOrBlank()) { + purchaseSkuByReceiptId[receiptId] = requestedSku + } + if (productType != null && receiptId.isNotBlank()) { + purchaseTypeByReceiptId[receiptId] = productType + } + if (productType != null && sku.isNotBlank()) { + productTypeBySku[sku] = productType + } + } + + private fun canonicalSkuForReceipt(receipt: AmazonReceipt): String? { + val receiptId = receipt.receiptId.orEmpty() + if (receiptId.isBlank()) return null + return purchaseSkuByReceiptId[receiptId] + } + + private fun cacheProductType(product: AmazonProduct) { + val sku = runCatching { product.sku }.getOrNull() + ?.takeIf { it.isNotBlank() } + ?: return + val productType = runCatching { product.productType }.getOrNull() ?: return + productTypeBySku[sku] = productType + } + + private fun AmazonReceipt.productTypeOrNull(): AmazonProductType? { + return runCatching { productType }.getOrNull() + } + + private fun Throwable.toOpenIapError(defaultMessage: String): OpenIapError { + return this as? OpenIapError + ?: OpenIapError.PurchaseFailed("$defaultMessage: ${message ?: javaClass.simpleName}") + } + + private suspend fun awaitPurchaseUpdates(reset: Boolean): PurchaseUpdatesResponse { + val requestId = withContext(Dispatchers.Main) { + ensureRegistered() + val request = runCatching { + PurchasingService.getPurchaseUpdates(reset) + } + .getOrElse { + throw OpenIapError.RestoreFailed + } + ?: throw OpenIapError.RestoreFailed + request.toString() + } + return awaitAmazonResponse( + requestId, + purchaseUpdatesRequests, + earlyPurchaseUpdatesResponses + ) + } + + private suspend fun awaitAmazonResponse( + requestId: String, + pending: ConcurrentHashMap>, + earlyResponses: ConcurrentHashMap + ): T { + val earlyResponse = earlyResponses.remove(requestId) + if (earlyResponse != null) return earlyResponse + + val deferred = CompletableDeferred() + pending[requestId] = deferred + earlyResponses.remove(requestId)?.let { response -> + pending.remove(requestId) + return response + } + + return try { + withTimeout(AMAZON_REQUEST_TIMEOUT_MS) { deferred.await() } + } catch (_: TimeoutCancellationException) { + timedOutRequestIds.add(requestId) + throw OpenIapError.ServiceTimeout("Amazon Appstore request timed out") + } finally { + pending.remove(requestId) + } + } + + private fun completeOrCache( + pending: ConcurrentHashMap>, + earlyResponses: ConcurrentHashMap, + requestId: String, + value: T + ) { + val deferred = pending.remove(requestId) + if (deferred != null) { + if (!deferred.isCompleted) deferred.complete(value) + } else if (timedOutRequestIds.remove(requestId)) { + OpenIapLog.w( + "Ignoring late Amazon Appstore response for timed-out request $requestId", + TAG + ) + } else { + earlyResponses[requestId] = value + } + } + + private fun updateStorefront(userData: UserData?) { + val countryCode = userData?.countryCode + storefrontCode = countryCode + ?: userData?.marketplace + ?: storefrontCode + } + + private suspend fun acknowledgePurchase(purchaseToken: String): Boolean = fulfillPurchase( + purchaseToken = purchaseToken, + operation = "acknowledge" + ) + + private suspend fun consumePurchase(purchaseToken: String): Boolean = fulfillPurchase( + purchaseToken = purchaseToken, + operation = "consume" + ) + + private suspend fun fulfillPurchase( + purchaseToken: String, + operation: String + ): Boolean = withContext(Dispatchers.IO) { + runCatching { + ensureRegistered() + PurchasingService.notifyFulfillment(purchaseToken, FulfillmentResult.FULFILLED) + true + }.getOrElse { + OpenIapLog.w("Amazon $operation failed: ${it.message}", TAG) + false + } + } + + private fun AmazonProduct.toInAppProduct(): ProductAndroid { + return ProductAndroid( + currency = "", + description = description.orEmpty(), + displayName = title, + displayPrice = price.orEmpty(), + id = sku, + nameAndroid = title.orEmpty(), + platform = IapPlatform.Android, + price = price.toPriceAmount(), + productStatusAndroid = ProductStatusAndroid.Ok, + title = title.orEmpty(), + type = ProductType.InApp + ) + } + + private fun AmazonProduct.toSubscriptionProduct(): ProductSubscriptionAndroid { + val subscriptionPeriod = this.subscriptionPeriod.toIsoBillingPeriod() + val priceAmount = price.toPriceAmount() + val phase = PricingPhaseAndroid( + billingCycleCount = 0, + billingPeriod = subscriptionPeriod, + formattedPrice = price.orEmpty(), + priceAmountMicros = "0", + priceCurrencyCode = "", + recurrenceMode = 1 + ) + val phases = PricingPhasesAndroid(listOf(phase)) + val legacyOffer = ProductSubscriptionAndroidOfferDetails( + basePlanId = sku, + offerId = null, + offerTags = emptyList(), + offerToken = "", + pricingPhases = phases + ) + val standardizedOffer = SubscriptionOffer( + basePlanIdAndroid = sku, + currency = "", + displayPrice = price.orEmpty(), + id = sku, + offerTagsAndroid = emptyList(), + offerTokenAndroid = "", + paymentMode = PaymentMode.PayAsYouGo, + period = null, + price = priceAmount, + pricingPhasesAndroid = phases, + type = DiscountOfferType.Introductory + ) + return ProductSubscriptionAndroid( + currency = "", + description = description.orEmpty(), + displayName = title, + displayPrice = price.orEmpty(), + id = sku, + nameAndroid = title.orEmpty(), + platform = IapPlatform.Android, + price = priceAmount, + productStatusAndroid = ProductStatusAndroid.Ok, + subscriptionOfferDetailsAndroid = listOf(legacyOffer), + subscriptionOffers = listOf(standardizedOffer), + title = title.orEmpty(), + type = ProductType.Subs + ) + } + + private fun String?.toIsoBillingPeriod(): String { + val value = this?.trim().orEmpty() + if (value.isEmpty() || value.startsWith("P")) return value + + return when (value.lowercase(Locale.ROOT)) { + "weekly", "week", "1 week" -> "P1W" + "biweekly", "bi-weekly", "bi weekly", "2 week", "2 weeks" -> "P2W" + "monthly", "month", "1 month" -> "P1M" + "bi-monthly", "bimonthly", "2 month", "2 months" -> "P2M" + "quarterly", "quarter", "3 months" -> "P3M" + "semiannual", "semiannually", "semi-annual", "semi-annually", "6 months" -> "P6M" + "annual", "annually", "yearly", "year", "1 year" -> "P1Y" + else -> value + } + } + + private fun String?.toPriceAmount(): Double { + return AmazonPriceParser.toPriceAmount(this) + } + + private fun AmazonReceipt.toPurchase( + productTypeOverride: AmazonProductType? = productTypeOrNull(), + productIdOverride: String? = null + ): PurchaseAndroid { + val dateMillis = purchaseDate?.time?.toDouble() ?: 0.0 + val receiptCanceled = isCanceled || cancelDate != null + val receiptDeferred = isDeferred + return buildAmazonPurchase( + packageName = context.packageName, + receiptId = receiptId, + receiptSku = sku, + isSubscription = productTypeOverride == AmazonProductType.SUBSCRIPTION, + purchaseDateMillis = dateMillis, + isCanceled = receiptCanceled, + isDeferred = receiptDeferred, + productIdOverride = productIdOverride + ).copy(dataAndroid = toJSON().toString()) + } + +} diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt new file mode 100644 index 00000000..0fd0f068 --- /dev/null +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt @@ -0,0 +1,25 @@ +package dev.hyo.openiap.store + +import android.content.Context +import dev.hyo.openiap.AlternativeBillingMode +import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.listener.UserChoiceBillingListener + +/** + * Amazon-specific extensions for OpenIapStore. + * These constructors are only available in the Amazon flavor. + */ + +/** + * Convenience constructor that creates OpenIapModule (Amazon flavor). + * + * @param context Android context + * @param alternativeBillingMode Ignored by Amazon; kept for API compatibility + * @param userChoiceBillingListener Ignored by Amazon; kept for API compatibility + */ +fun OpenIapStore( + context: Context, + alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE, + userChoiceBillingListener: UserChoiceBillingListener? = null +): OpenIapStore = OpenIapStore(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener) as OpenIapProtocol) diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index 8b67acea..6205d9ec 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -62,6 +62,7 @@ private const val TAG = "OpenIapModule" * @param userChoiceBillingListener Listener for user choice billing selection (optional) * * Note: Oculus App ID is read from AndroidManifest.xml meta-data with key "com.oculus.vr.APP_ID" + * or "com.meta.horizon.platform.ovr.OCULUS_APP_ID". */ class OpenIapModule( private val context: Context, @@ -79,18 +80,20 @@ class OpenIapModule( private const val PURCHASE_QUERY_DELAY_MS = 500L } - // Read Oculus App ID from AndroidManifest.xml + // Read Oculus App ID from AndroidManifest.xml. private val appId: String? by lazy { try { val appInfo = context.packageManager.getApplicationInfo( context.packageName, android.content.pm.PackageManager.GET_META_DATA ) - val id = appInfo.metaData?.getString("com.oculus.vr.APP_ID") + val metaData = appInfo.metaData + val id = metaData?.getString("com.oculus.vr.APP_ID") + ?: metaData?.getString("com.meta.horizon.platform.ovr.OCULUS_APP_ID") OpenIapLog.d("Read Oculus App ID from manifest: $id", TAG) id } catch (e: Exception) { - OpenIapLog.w("Failed to read com.oculus.vr.APP_ID from AndroidManifest.xml: ${e.message}", TAG) + OpenIapLog.w("Failed to read Oculus App ID from AndroidManifest.xml: ${e.message}", TAG) null } } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index c1674700..8cb1d8d8 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -576,7 +576,8 @@ public enum class IapStore(val rawValue: String) { Unknown("unknown"), Apple("apple"), Google("google"), - Horizon("horizon"); + Horizon("horizon"), + Amazon("amazon"); companion object { fun fromJson(value: String): IapStore = when (value) { @@ -588,6 +589,8 @@ public enum class IapStore(val rawValue: String) { "Google" -> IapStore.Google "horizon" -> IapStore.Horizon "Horizon" -> IapStore.Horizon + "amazon" -> IapStore.Amazon + "Amazon" -> IapStore.Amazon else -> throw IllegalArgumentException("Unknown IapStore value: $value") } } @@ -4500,7 +4503,8 @@ public data class RequestPurchaseProps( * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ public data class RequestPurchasePropsByPlatforms( @@ -4704,7 +4708,8 @@ public data class RequestSubscriptionIosProps( * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ public data class RequestSubscriptionPropsByPlatforms( @@ -4744,6 +4749,41 @@ public data class RequestSubscriptionPropsByPlatforms( ) } +public data class RequestVerifyPurchaseWithIapkitAmazonProps( + /** + * Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + */ + val receiptId: String, + /** + * Use Amazon RVS Cloud Sandbox for App Tester receipts. + */ + val sandbox: Boolean? = null, + /** + * Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + */ + val userId: String? = null +) { + companion object { + fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAmazonProps? { + val receiptId = json["receiptId"] as? String + val sandbox = json["sandbox"] as? Boolean + val userId = json["userId"] as? String + if (receiptId == null) return null + return RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId = receiptId, + sandbox = sandbox, + userId = userId, + ) + } + } + + fun toJson(): Map = mapOf( + "receiptId" to receiptId, + "sandbox" to sandbox, + "userId" to userId, + ) +} + public data class RequestVerifyPurchaseWithIapkitAppleProps( /** * The JWS token returned with the purchase response. @@ -4791,8 +4831,13 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps( * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) */ public data class RequestVerifyPurchaseWithIapkitProps( + /** + * Amazon Appstore verification parameters. + */ + val amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = null, /** * API key used for the Authorization header (Bearer {apiKey}). */ @@ -4809,6 +4854,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitProps { return RequestVerifyPurchaseWithIapkitProps( + amazon = (json["amazon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(it) }, apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, @@ -4817,6 +4863,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( } fun toJson(): Map = mapOf( + "amazon" to amazon?.toJson(), "apiKey" to apiKey, "apple" to apple?.toJson(), "google" to google?.toJson(), diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index 927063a8..ec23bfbe 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -57,7 +57,7 @@ import kotlinx.coroutines.launch /** * OpenIapStore (Android) - * Convenience store that wraps an [OpenIapProtocol] implementation (Play Store or Horizon) + * Convenience store that wraps an [OpenIapProtocol] implementation (Play, Horizon, or Amazon) * and exposes suspend APIs with observable StateFlows for UI layers to consume. */ class OpenIapStore(private val module: OpenIapProtocol) { @@ -748,6 +748,10 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI OpenIapLog.d("Loading OpenIapModule (Horizon flavor)", "OpenIapStore") loadHorizonModule(context) } + "amazon", "fireos", "fire" -> { + OpenIapLog.d("Loading OpenIapModule (Amazon flavor)", "OpenIapStore") + loadAmazonModule(context) + } else -> { // Default to Play Store (includes "play", "google", "gplay", "googleplay", "gms") OpenIapLog.d("Loading OpenIapModule (Play flavor)", "OpenIapStore") @@ -787,6 +791,36 @@ private fun loadHorizonModule(context: Context): OpenIapProtocol { } } +/** + * Load OpenIapModule (Amazon flavor) via reflection + * Note: Amazon flavor uses the same package and class name as Play flavor + */ +private fun loadAmazonModule(context: Context): OpenIapProtocol { + return try { + val clazz = Class.forName("dev.hyo.openiap.OpenIapModule") + val alternativeBillingModeClass = Class.forName("dev.hyo.openiap.AlternativeBillingMode") + val userChoiceBillingListenerClass = Class.forName("dev.hyo.openiap.listener.UserChoiceBillingListener") + val developerProvidedBillingListenerClass = Class.forName("dev.hyo.openiap.listener.DeveloperProvidedBillingListener") + + val constructor = clazz.getConstructor( + Context::class.java, + alternativeBillingModeClass, + userChoiceBillingListenerClass, + developerProvidedBillingListenerClass + ) + + val noneMode = alternativeBillingModeClass.enumConstants?.first { + (it as Enum<*>).name == "NONE" + } + + val instance = constructor.newInstance(context, noneMode, null, null) as OpenIapProtocol + OpenIapLog.d("Successfully loaded OpenIapModule (Amazon flavor)", "OpenIapStore") + instance + } catch (e: Throwable) { + throw IllegalStateException("Failed to load OpenIapModule (Amazon flavor). Make sure you're using the Amazon flavor.", e) + } +} + /** * Load OpenIapModule (Play flavor) via reflection */ diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index cf829a81..ba393517 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import com.google.gson.JsonSyntaxException import com.google.gson.reflect.TypeToken import dev.hyo.openiap.IapStore +import dev.hyo.openiap.IapkitPurchaseState import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapLog import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps @@ -17,6 +18,7 @@ import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder +import java.util.Locale private const val DEFAULT_IAPKIT_ENDPOINT = "https://kit.openiap.dev/v1/purchase/verify" private val gson = Gson() @@ -168,16 +170,100 @@ suspend fun verifyPurchaseWithIapkit( tag: String, connectionFactory: (String) -> HttpURLConnection = ::openConnection ): RequestVerifyPurchaseWithIapkitResult = withContext(Dispatchers.IO) { + fun malformedIapkitResponse(): OpenIapError.PurchaseVerificationFailed = + OpenIapError.PurchaseVerificationFailed("IAPKit returned malformed response") + val endpoint = DEFAULT_IAPKIT_ENDPOINT - // On Android, only Google verification is supported via IAPKit - // Note: Horizon verification requires direct S2S API calls to Meta (not yet supported) - if (props.google == null) { - throw IllegalArgumentException("IAPKit verification on Android requires google payload") + val hasApple = props.apple != null + val hasGoogle = props.google != null + val hasAmazon = props.amazon != null + if (listOf(hasApple, hasGoogle, hasAmazon).count { it } != 1 || hasApple) { + throw IllegalArgumentException( + "IAPKit verification on Android requires exactly one google or amazon payload" + ) + } + + fun buildGooglePayload(): Map { + val google = props.google + ?: throw IllegalArgumentException("IAPKit Google verification requires google options") + if (google.purchaseToken.isBlank()) { + throw IllegalArgumentException("IAPKit Google verification requires purchaseToken") + } + return mutableMapOf( + "store" to IapStore.Google.rawValue, + "purchaseToken" to google.purchaseToken + ) + } + + fun buildAmazonPayload(): Map { + val amazon = props.amazon + ?: throw IllegalArgumentException("IAPKit Amazon verification requires amazon options") + val userId = amazon.userId?.trim().orEmpty() + val receiptId = amazon.receiptId.trim() + if (userId.isBlank() || receiptId.isBlank()) { + throw IllegalArgumentException("IAPKit Amazon verification requires userId and receiptId") + } + return mutableMapOf( + "store" to IapStore.Amazon.rawValue, + "userId" to userId, + "receiptId" to receiptId + ).apply { + amazon.sandbox?.let { put("sandbox", it) } + } + } + + @Suppress("UNCHECKED_CAST") + fun extractIapkitErrorMessage(json: Map): String? { + fun extractStringMessage(value: String): String { + return try { + val nested = gson.fromJson(value, Map::class.java) as? Map + if (nested != null) { + extractIapkitErrorMessage(nested) ?: value + } else { + value + } + } catch (e: Exception) { + value + } + } + + val errorsRaw = json["errors"] + if (errorsRaw is List<*>) { + val firstError = errorsRaw.firstOrNull() + if (firstError is Map<*, *>) { + return extractIapkitErrorMessage(firstError as Map) + } + } + + val detailsRaw = json["details"] + if (detailsRaw is Map<*, *>) { + val details = detailsRaw as Map + val originalError = details["originalError"] + if (originalError is String) { + return extractStringMessage(originalError) + } + } + + val message = json["message"] as? String + if (message != null) { + return extractStringMessage(message) + } + + val error = json["error"] as? String + if (error != null) { + return extractStringMessage(error) + } + + return null } - val store = IapStore.Google - val payload = buildGooglePayload(props) + val store = if (hasAmazon) IapStore.Amazon else IapStore.Google + val payload = when (store) { + IapStore.Amazon -> buildAmazonPayload() + IapStore.Google -> buildGooglePayload() + else -> throw IllegalArgumentException("IAPKit verification on Android does not support ${store.rawValue}") + } val connection = connectionFactory(endpoint).apply { requestMethod = "POST" @@ -190,6 +276,47 @@ suspend fun verifyPurchaseWithIapkit( try { val body = gson.toJson(payload) + val mapType = object : TypeToken>() {}.type + + fun parseIapkitObject(responseBody: String): Map { + return try { + gson.fromJson>(responseBody, mapType) + ?: throw malformedIapkitResponse() + } catch (error: JsonSyntaxException) { + OpenIapLog.warn("Failed to parse IAPKit verification response: ${error.message}", tag) + throw OpenIapError.PurchaseVerificationFailed("Failed to parse response") + } + } + + fun readIapkitResult(parsed: Map): RequestVerifyPurchaseWithIapkitResult { + val errorsRaw = parsed["errors"] + if (errorsRaw is List<*> && errorsRaw.isNotEmpty()) { + val errorMessage = extractIapkitErrorMessage(parsed) ?: "IAPKit verification failed" + throw OpenIapError.PurchaseVerificationFailed(errorMessage) + } + + val isValid = parsed["isValid"] as? Boolean + ?: throw malformedIapkitResponse() + val state = parsed["state"] as? String + ?: throw malformedIapkitResponse() + val responseStore = (parsed["store"] as? String) + ?.let { runCatching { IapStore.fromJson(it) }.getOrNull() } + ?: throw malformedIapkitResponse() + if (responseStore != store) { + throw malformedIapkitResponse() + } + + val normalizedState = state.lowercase(Locale.ROOT).replace("_", "-") + val parsedState = runCatching { + IapkitPurchaseState.fromJson(normalizedState) + }.getOrDefault(IapkitPurchaseState.Unknown) + + return RequestVerifyPurchaseWithIapkitResult( + isValid = isValid, + state = parsedState, + store = responseStore + ) + } connection.outputStream.use { stream -> stream.write(body.toByteArray(Charsets.UTF_8)) @@ -206,7 +333,6 @@ suspend fun verifyPurchaseWithIapkit( // Extract concise error message from IAPKit response // IAPKit returns nested error format - extract the deepest originalError val errorMessage = try { - val mapType = object : TypeToken>() {}.type val errorJson = gson.fromJson>(responseBody, mapType) extractIapkitErrorMessage(errorJson) ?: "HTTP $statusCode" } catch (e: Exception) { @@ -215,102 +341,13 @@ suspend fun verifyPurchaseWithIapkit( throw OpenIapError.PurchaseVerificationFailed(errorMessage) } - try { - val mapType = object : TypeToken>() {}.type - val parsed = gson.fromJson>(responseBody, mapType) - // IAPKit API returns UPPER_SNAKE_CASE (e.g., "PURCHASED", "PENDING_ACKNOWLEDGMENT") - // Types.kt expects lower-kebab-case (e.g., "purchased", "pending-acknowledgment") - val normalizedParsed = parsed.toMutableMap().apply { - val state = this["state"] as? String - if (state != null) { - this["state"] = state.lowercase().replace("_", "-") - } - // IAPKit response doesn't include store, add it from request - if (this["store"] == null) { - this["store"] = store.toJson() - } - } - RequestVerifyPurchaseWithIapkitResult.fromJson(normalizedParsed) - } catch (jsonError: Exception) { - OpenIapLog.warn("Failed to parse IAPKit verification response: ${jsonError.message}", tag) - throw OpenIapError.PurchaseVerificationFailed("Failed to parse response") - } + readIapkitResult(parseIapkitObject(responseBody)) } catch (io: IOException) { OpenIapLog.warn("Network error during IAPKit verification: ${io.message}", tag) - throw OpenIapError.PurchaseVerificationFailed("Network error: ${io.message}") + throw OpenIapError.NetworkError } finally { connection.disconnect() } } -/** - * Build payload for Google Play Store verification via IAPKit. - */ -private fun buildGooglePayload(props: RequestVerifyPurchaseWithIapkitProps): Map { - val google = props.google - ?: throw IllegalArgumentException("IAPKit Google verification requires google options") - if (google.purchaseToken.isBlank()) { - throw IllegalArgumentException("IAPKit Google verification requires purchaseToken") - } - return mutableMapOf( - "store" to IapStore.Google.rawValue, - "purchaseToken" to google.purchaseToken - ) -} - - private fun String?.orElse(fallback: String): String = this ?: fallback - -/** - * Extract concise error message from IAPKit error response. - * IAPKit returns nested error structures - we extract the deepest originalError for clarity. - * - * Example input: - * {"error":"PLAY_STORE_VERIFICATION_ERROR","message":"Failed to verify Google Play purchase: {...}", - * "details":{"originalError":"..."}} - * - * Returns: "The purchase token is no longer valid." (the deepest originalError) - */ -@Suppress("UNCHECKED_CAST") -private fun extractIapkitErrorMessage(json: Map): String? { - // Try errors array format first: { "errors": [{ "code": "...", "message": "..." }] } - val errorsRaw = json["errors"] - if (errorsRaw is List<*>) { - val firstError = errorsRaw.firstOrNull() - if (firstError is Map<*, *>) { - val errorMap = firstError as Map - // Recursively extract from first error - return extractIapkitErrorMessage(errorMap) - } - } - - // Try to get details.originalError (deepest level) - val detailsRaw = json["details"] - if (detailsRaw is Map<*, *>) { - val details = detailsRaw as Map - val originalError = details["originalError"] - if (originalError is String) { - // originalError might be a JSON string, try to parse it - return try { - val nested = gson.fromJson(originalError, Map::class.java) as? Map - if (nested != null) { - extractIapkitErrorMessage(nested) ?: originalError - } else { - originalError - } - } catch (e: Exception) { - // Not JSON, use as-is - originalError - } - } - } - - // Try message field, but avoid the verbose nested JSON string - val message = json["message"] as? String - if (message != null && !message.contains("{\"error\"")) { - return message - } - - // Fallback to error code - return json["error"] as? String -} diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt index d9d691e3..571e4b97 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt @@ -1,11 +1,23 @@ package dev.hyo.openiap -import com.android.billingclient.api.BillingClient import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +private const val BILLING_RESPONSE_USER_CANCELED = 1 +private const val BILLING_RESPONSE_SERVICE_UNAVAILABLE = 2 +private const val BILLING_RESPONSE_BILLING_UNAVAILABLE = 3 +private const val BILLING_RESPONSE_ITEM_UNAVAILABLE = 4 +private const val BILLING_RESPONSE_DEVELOPER_ERROR = 5 +private const val BILLING_RESPONSE_ERROR = 6 +private const val BILLING_RESPONSE_ITEM_ALREADY_OWNED = 7 +private const val BILLING_RESPONSE_ITEM_NOT_OWNED = 8 +private const val BILLING_RESPONSE_SERVICE_DISCONNECTED = -1 +private const val BILLING_RESPONSE_FEATURE_NOT_SUPPORTED = -2 +private const val BILLING_RESPONSE_SERVICE_TIMEOUT = -3 +private const val BILLING_PRODUCT_TYPE_SUBS = "subs" + class OpenIapErrorTest { @Test @@ -119,10 +131,10 @@ class OpenIapErrorTest { @Test fun `QueryProduct carries billing diagnostics when provided`() { val error: OpenIapError = OpenIapError.QueryProduct.withDiagnostics( - responseCode = BillingClient.BillingResponseCode.DEVELOPER_ERROR, + responseCode = BILLING_RESPONSE_DEVELOPER_ERROR, debugMessage = "Invalid product ID", productIds = listOf("premium_monthly", "lifetime"), - productType = BillingClient.ProductType.SUBS, + productType = BILLING_PRODUCT_TYPE_SUBS, isEmptyProductList = true, ) val json = error.toJSON() @@ -132,15 +144,15 @@ class OpenIapErrorTest { assertEquals(ErrorCode.QueryProduct.rawValue, error.code) assertEquals("Failed to query product", error.message) val queryError = error as OpenIapError.QueryProduct - assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, queryError.responseCode) + assertEquals(BILLING_RESPONSE_DEVELOPER_ERROR, queryError.responseCode) assertEquals("Invalid product ID", error.debugMessage) assertEquals(listOf("premium_monthly", "lifetime"), queryError.productIds) - assertEquals(BillingClient.ProductType.SUBS, queryError.productType) + assertEquals(BILLING_PRODUCT_TYPE_SUBS, queryError.productType) assertEquals(true, queryError.isEmptyProductList) - assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, json["responseCode"]) + assertEquals(BILLING_RESPONSE_DEVELOPER_ERROR, json["responseCode"]) assertEquals("Invalid product ID", json["debugMessage"]) assertEquals(listOf("premium_monthly", "lifetime"), json["productIds"]) - assertEquals(BillingClient.ProductType.SUBS, json["productType"]) + assertEquals(BILLING_PRODUCT_TYPE_SUBS, json["productType"]) assertEquals(true, json["isEmptyProductList"]) } @@ -307,17 +319,17 @@ class OpenIapErrorTest { @Test @Suppress("DEPRECATION") fun `fromBillingResponseCode returns correct error for known response codes`() { - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.USER_CANCELED) is OpenIapError.UserCancelled) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) is OpenIapError.ServiceUnavailable) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) is OpenIapError.BillingUnavailable) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.ITEM_UNAVAILABLE) is OpenIapError.ItemUnavailable) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.DEVELOPER_ERROR) is OpenIapError.DeveloperError) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.ERROR) is OpenIapError.BillingError) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) is OpenIapError.ItemAlreadyOwned) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.ITEM_NOT_OWNED) is OpenIapError.ItemNotOwned) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED) is OpenIapError.ServiceDisconnected) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED) is OpenIapError.FeatureNotSupported) - assertTrue(OpenIapError.fromBillingResponseCode(BillingClient.BillingResponseCode.SERVICE_TIMEOUT) is OpenIapError.ServiceTimeout) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_USER_CANCELED) is OpenIapError.UserCancelled) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_SERVICE_UNAVAILABLE) is OpenIapError.ServiceUnavailable) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_BILLING_UNAVAILABLE) is OpenIapError.BillingUnavailable) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_ITEM_UNAVAILABLE) is OpenIapError.ItemUnavailable) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_DEVELOPER_ERROR) is OpenIapError.DeveloperError) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_ERROR) is OpenIapError.BillingError) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_ITEM_ALREADY_OWNED) is OpenIapError.ItemAlreadyOwned) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_ITEM_NOT_OWNED) is OpenIapError.ItemNotOwned) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_SERVICE_DISCONNECTED) is OpenIapError.ServiceDisconnected) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_FEATURE_NOT_SUPPORTED) is OpenIapError.FeatureNotSupported) + assertTrue(OpenIapError.fromBillingResponseCode(BILLING_RESPONSE_SERVICE_TIMEOUT) is OpenIapError.ServiceTimeout) } @Test @@ -331,17 +343,17 @@ class OpenIapErrorTest { fun `fromBillingResponseCode forwards debugMessage for every response code`() { val debug = "offerToken does not match any product details" val codesToAssert = listOf( - BillingClient.BillingResponseCode.USER_CANCELED, - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, - BillingClient.BillingResponseCode.BILLING_UNAVAILABLE, - BillingClient.BillingResponseCode.ITEM_UNAVAILABLE, - BillingClient.BillingResponseCode.DEVELOPER_ERROR, - BillingClient.BillingResponseCode.ERROR, - BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED, - BillingClient.BillingResponseCode.ITEM_NOT_OWNED, - BillingClient.BillingResponseCode.SERVICE_DISCONNECTED, - BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED, - BillingClient.BillingResponseCode.SERVICE_TIMEOUT, + BILLING_RESPONSE_USER_CANCELED, + BILLING_RESPONSE_SERVICE_UNAVAILABLE, + BILLING_RESPONSE_BILLING_UNAVAILABLE, + BILLING_RESPONSE_ITEM_UNAVAILABLE, + BILLING_RESPONSE_DEVELOPER_ERROR, + BILLING_RESPONSE_ERROR, + BILLING_RESPONSE_ITEM_ALREADY_OWNED, + BILLING_RESPONSE_ITEM_NOT_OWNED, + BILLING_RESPONSE_SERVICE_DISCONNECTED, + BILLING_RESPONSE_FEATURE_NOT_SUPPORTED, + BILLING_RESPONSE_SERVICE_TIMEOUT, 999 // else branch → UnknownError ) codesToAssert.forEach { code -> diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt index 549edd02..ddba7c26 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt @@ -6,6 +6,8 @@ import dev.hyo.openiap.utils.verifyPurchaseWithHorizon import dev.hyo.openiap.utils.verifyPurchaseWithIapkit import dev.hyo.openiap.IapStore import dev.hyo.openiap.IapkitPurchaseState +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitAmazonProps +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitAppleProps import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps import dev.hyo.openiap.VerifyPurchaseGoogleOptions @@ -13,6 +15,7 @@ import dev.hyo.openiap.VerifyPurchaseHorizonOptions import dev.hyo.openiap.VerifyPurchaseProps import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.net.HttpURLConnection @@ -150,18 +153,19 @@ class PurchaseVerificationValidatorTest { } @Test - fun `verifyPurchaseWithIapkit throws without google props`() = runTest { + fun `verifyPurchaseWithIapkit throws without android store props`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( apiKey = null, apple = null, - google = null + google = null, + amazon = null ) try { verifyPurchaseWithIapkit(props, "TEST") { _ -> throw AssertionError("Connection should not be created when google props are missing") } - throw AssertionError("Expected IllegalArgumentException for missing google props") + throw AssertionError("Expected IllegalArgumentException for missing android store props") } catch (expected: IllegalArgumentException) { // Expected path } @@ -174,7 +178,8 @@ class PurchaseVerificationValidatorTest { apple = null, google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "token-abc" - ) + ), + amazon = null ) verifyPurchaseWithIapkit(props, "TEST") { _ -> @@ -189,7 +194,8 @@ class PurchaseVerificationValidatorTest { apple = null, google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "" - ) + ), + amazon = null ) try { @@ -209,7 +215,8 @@ class PurchaseVerificationValidatorTest { apple = null, google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "token-123" - ) + ), + amazon = null ) val connection = FakeHttpURLConnection(200, """{"store":"google","isValid":true,"state":"ENTITLED"}""") @@ -231,7 +238,8 @@ class PurchaseVerificationValidatorTest { apple = null, google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "token-123" - ) + ), + amazon = null ) val connection = FakeHttpURLConnection(200, """{"store":"google","isValid":false,"state":"INAUTHENTIC"}""") @@ -246,15 +254,136 @@ class PurchaseVerificationValidatorTest { } @Test - fun `verifyPurchaseWithIapkit wraps non-2xx as PurchaseVerificationFailed`() = runTest { + fun `verifyPurchaseWithIapkit posts amazon receipt details`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = "secret", + apple = null, + google = null, + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + userId = "amzn1.account.ABC123", + receiptId = "amzn1.receipt.ABC123456789", + sandbox = true + ) + ) + + val connection = FakeHttpURLConnection(200, """{"store":"amazon","isValid":true,"state":"ENTITLED"}""") + val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> connection } + + assertEquals(IapStore.Amazon, result.store) + assertTrue(result.isValid) + assertEquals(IapkitPurchaseState.Entitled, result.state) + assertEquals("Bearer secret", connection.headers["Authorization"]) + + val bodyMap = Gson().fromJson(requireNotNull(connection.writtenBody), Map::class.java) as Map<*, *> + assertEquals("amazon", bodyMap["store"]) + assertEquals("amzn1.account.ABC123", bodyMap["userId"]) + assertEquals("amzn1.receipt.ABC123456789", bodyMap["receiptId"]) + assertEquals(true, bodyMap["sandbox"]) + } + + @Test + fun `verifyPurchaseWithIapkit throws when amazon payload is incomplete`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = null, + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + userId = "", + receiptId = "amzn1.receipt.ABC123456789", + sandbox = false + ) + ) + + try { + verifyPurchaseWithIapkit(props, "TEST") { _ -> + throw AssertionError("Connection should not be created when amazon payload is invalid") + } + throw AssertionError("Expected IllegalArgumentException for invalid amazon payload") + } catch (expected: IllegalArgumentException) { + // Expected path + } + } + + @Test + fun `verifyPurchaseWithIapkit throws when multiple android payloads are provided`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( apiKey = null, apple = null, google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "token-123" + ), + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + userId = "amzn1.account.ABC123", + receiptId = "amzn1.receipt.ABC123456789", + sandbox = false ) ) + try { + verifyPurchaseWithIapkit(props, "TEST") { _ -> + throw AssertionError("Connection should not be created when multiple payloads are provided") + } + throw AssertionError("Expected IllegalArgumentException for multiple payloads") + } catch (expected: IllegalArgumentException) { + // Expected path + } + } + + @Test + fun `verifyPurchaseWithIapkit throws when apple payload is provided on Android`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = RequestVerifyPurchaseWithIapkitAppleProps( + jws = "header.payload.signature" + ), + google = null, + amazon = null + ) + + try { + verifyPurchaseWithIapkit(props, "TEST") { _ -> + throw AssertionError("Connection should not be created when apple payload is provided") + } + throw AssertionError("Expected IllegalArgumentException for apple payload on Android") + } catch (expected: IllegalArgumentException) { + assertTrue(expected.message?.contains("exactly one google or amazon") == true) + } + } + + @Test + fun `verifyPurchaseWithIapkit throws when apple is mixed with android payloads`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = RequestVerifyPurchaseWithIapkitAppleProps( + jws = "header.payload.signature" + ), + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = "token-123" + ), + amazon = null + ) + + try { + verifyPurchaseWithIapkit(props, "TEST") { _ -> + throw AssertionError("Connection should not be created when apple payload is mixed") + } + throw AssertionError("Expected IllegalArgumentException for mixed apple payload") + } catch (expected: IllegalArgumentException) { + assertTrue(expected.message?.contains("exactly one google or amazon") == true) + } + } + + @Test + fun `verifyPurchaseWithIapkit wraps non-2xx as PurchaseVerificationFailed`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = "token-123" + ), + amazon = null + ) + try { verifyPurchaseWithIapkit( props, @@ -267,6 +396,125 @@ class PurchaseVerificationValidatorTest { } } + @Test + fun `verifyPurchaseWithIapkit extracts nested JSON error messages`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = "token-123" + ), + amazon = null + ) + + try { + verifyPurchaseWithIapkit( + props, + "TEST" + ) { + FakeHttpURLConnection( + 400, + """{"message":"{\"error\":\"receipt no longer valid\"}"}""" + ) + } + throw AssertionError("Expected PurchaseVerificationFailed for non-2xx response") + } catch (error: OpenIapError.PurchaseVerificationFailed) { + assertTrue(error.message.contains("receipt no longer valid")) + } + } + + @Test + fun `verifyPurchaseWithIapkit rejects successful error payloads`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = "token-123" + ), + amazon = null + ) + + try { + verifyPurchaseWithIapkit( + props, + "TEST" + ) { _ -> FakeHttpURLConnection(200, """{"errors":[{"message":"bad receipt"}]}""") } + throw AssertionError("Expected PurchaseVerificationFailed for IAPKit error payload") + } catch (error: OpenIapError.PurchaseVerificationFailed) { + assertTrue(error.message.contains("bad receipt")) + } + } + + @Test + fun `verifyPurchaseWithIapkit rejects missing result fields`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = null, + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + userId = "amzn1.account.ABC123", + receiptId = "amzn1.receipt.ABC123456789", + sandbox = true + ) + ) + + try { + verifyPurchaseWithIapkit( + props, + "TEST" + ) { _ -> FakeHttpURLConnection(200, """{"store":"amazon","state":"ENTITLED"}""") } + throw AssertionError("Expected PurchaseVerificationFailed for missing fields") + } catch (error: OpenIapError.PurchaseVerificationFailed) { + assertTrue(error.message.contains("malformed")) + } + } + + @Test + fun `verifyPurchaseWithIapkit rejects mismatched result store`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = null, + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + userId = "amzn1.account.ABC123", + receiptId = "amzn1.receipt.ABC123456789", + sandbox = true + ) + ) + + try { + verifyPurchaseWithIapkit( + props, + "TEST" + ) { _ -> FakeHttpURLConnection(200, """{"store":"google","isValid":true,"state":"ENTITLED"}""") } + throw AssertionError("Expected PurchaseVerificationFailed for mismatched store") + } catch (error: OpenIapError.PurchaseVerificationFailed) { + assertTrue(error.message.contains("malformed")) + } + } + + @Test + fun `verifyPurchaseWithIapkit wraps IO failures as NetworkError`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = "token-123" + ), + amazon = null + ) + + try { + verifyPurchaseWithIapkit( + props, + "TEST" + ) { _ -> FailingHttpURLConnection() } + throw AssertionError("Expected NetworkError for IO failure") + } catch (error: OpenIapError.NetworkError) { + assertTrue(true) + } + } + // ===== Horizon verification tests ===== @Test @@ -359,3 +607,15 @@ private class FakeHttpURLConnection( override fun connect() { /* no-op */ } } + +private class FailingHttpURLConnection : HttpURLConnection(URL("https://example.com")) { + override fun getOutputStream(): OutputStream { + throw IOException("network offline") + } + + override fun disconnect() { /* no-op */ } + + override fun usingProxy(): Boolean = false + + override fun connect() { /* no-op */ } +} diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonPriceParserTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonPriceParserTest.kt new file mode 100644 index 00000000..8d1bc28f --- /dev/null +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonPriceParserTest.kt @@ -0,0 +1,44 @@ +package dev.hyo.openiap + +import java.util.Locale +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class AmazonPriceParserTest { + private lateinit var defaultLocale: Locale + + @Before + fun setUp() { + defaultLocale = Locale.getDefault() + Locale.setDefault(Locale.US) + } + + @After + fun tearDown() { + Locale.setDefault(defaultLocale) + } + + @Test + fun parsesInternationalFormattedPrices() { + val cases = mapOf( + "\$1,234.56" to 1234.56, + "\$1,234" to 1234.0, + "1.234,56 €" to 1234.56, + "1 234,56 руб" to 1234.56, + "JPY 1,000" to 1000.0, + "¥1000" to 1000.0, + "TND 1.234" to 1.234 + ) + + for ((displayPrice, expected) in cases) { + assertEquals( + displayPrice, + expected, + AmazonPriceParser.toPriceAmount(displayPrice), + 0.0001 + ) + } + } +} diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonSubscriptionGroupMappingTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonSubscriptionGroupMappingTest.kt new file mode 100644 index 00000000..748442c9 --- /dev/null +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonSubscriptionGroupMappingTest.kt @@ -0,0 +1,66 @@ +package dev.hyo.openiap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AmazonSubscriptionGroupMappingTest { + + @Test + fun `requested subscription skus stay isolated across multiple groups`() { + val requestedSkuByReceiptId = mutableMapOf( + "receipt-premium-monthly" to "dev.hyo.martie.premium.monthly", + "receipt-pro-monthly" to "dev.hyo.martie.pro.monthly" + ) + + val premiumPurchase = buildAmazonPurchase( + packageName = "dev.hyo.martie", + receiptId = "receipt-premium-monthly", + receiptSku = "amazon-receipt-premium", + isSubscription = true, + purchaseDateMillis = 1_700_000_000_000.0, + isCanceled = false, + isDeferred = false, + productIdOverride = requestedSkuByReceiptId["receipt-premium-monthly"] + ) + val proPurchase = buildAmazonPurchase( + packageName = "dev.hyo.martie", + receiptId = "receipt-pro-monthly", + receiptSku = "amazon-receipt-pro", + isSubscription = true, + purchaseDateMillis = 1_700_000_000_000.0, + isCanceled = false, + isDeferred = false, + productIdOverride = requestedSkuByReceiptId["receipt-pro-monthly"] + ) + + assertEquals("dev.hyo.martie.premium.monthly", premiumPurchase.productId) + assertEquals("dev.hyo.martie.premium.monthly", premiumPurchase.currentPlanId) + assertEquals(listOf("dev.hyo.martie.premium.monthly"), premiumPurchase.ids) + assertEquals("dev.hyo.martie.pro.monthly", proPurchase.productId) + assertEquals("dev.hyo.martie.pro.monthly", proPurchase.currentPlanId) + assertEquals(listOf("dev.hyo.martie.pro.monthly"), proPurchase.ids) + } + + @Test + fun `restored subscription without in flight request uses receipt sku`() { + val requestedSkuByReceiptId = emptyMap() + + assertNull(requestedSkuByReceiptId["receipt-restored-premium"]) + + val purchase = buildAmazonPurchase( + packageName = "dev.hyo.martie", + receiptId = "receipt-restored-premium", + receiptSku = "dev.hyo.martie.premium.monthly", + isSubscription = true, + purchaseDateMillis = 1_700_000_000_000.0, + isCanceled = false, + isDeferred = false, + productIdOverride = requestedSkuByReceiptId["receipt-restored-premium"] + ) + + assertEquals("dev.hyo.martie.premium.monthly", purchase.productId) + assertEquals("dev.hyo.martie.premium.monthly", purchase.currentPlanId) + assertEquals(listOf("dev.hyo.martie.premium.monthly"), purchase.ids) + } +} diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/FetchProductsAmazonTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/FetchProductsAmazonTest.kt new file mode 100644 index 00000000..2fff35a3 --- /dev/null +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/FetchProductsAmazonTest.kt @@ -0,0 +1,40 @@ +package dev.hyo.openiap + +import android.content.ContextWrapper +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test + +class FetchProductsAmazonTest { + + @Test + fun `empty sku list with null type returns empty all result`() = runBlocking { + val module = OpenIapModule(ContextWrapper(null)) + + val result = module.fetchProducts(ProductRequest(emptyList(), null)) + + assertTrue(result is FetchProductsResultAll) + assertTrue((result as FetchProductsResultAll).value.orEmpty().isEmpty()) + } + + @Test + fun `empty sku list with all type returns empty all result`() = runBlocking { + val module = OpenIapModule(ContextWrapper(null)) + + val result = module.fetchProducts(ProductRequest(emptyList(), ProductQueryType.All)) + + assertTrue(result is FetchProductsResultAll) + assertTrue((result as FetchProductsResultAll).value.orEmpty().isEmpty()) + } + + @Test + fun `empty sku list with product type throws EmptySkuList`() = runBlocking { + val module = OpenIapModule(ContextWrapper(null)) + + val thrown = runCatching { + module.fetchProducts(ProductRequest(emptyList(), ProductQueryType.InApp)) + }.exceptionOrNull() + + assertTrue(thrown is OpenIapError.EmptySkuList) + } +} diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/SubscriptionBillingIssueAmazonTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/SubscriptionBillingIssueAmazonTest.kt new file mode 100644 index 00000000..3f3fa1e1 --- /dev/null +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/SubscriptionBillingIssueAmazonTest.kt @@ -0,0 +1,52 @@ +package dev.hyo.openiap + +import android.content.ContextWrapper +import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +/** + * Amazon Appstore SDK has no subscription billing issue callback. The bundle + * still exposes the handler for API parity, but it must fail fast instead of + * suspending forever. + */ +class SubscriptionBillingIssueAmazonTest { + + @Test + fun `Amazon subscriptionBillingIssue handler throws FeatureNotSupported`() { + val module = OpenIapModule(ContextWrapper(null)) + + val handler = module.subscriptionHandlers.subscriptionBillingIssue + assertNotNull( + "Amazon flavor must wire subscriptionBillingIssue handler for bundle parity", + handler + ) + + val thrown = runCatching { runBlocking { withTimeout(5_000) { handler!!.invoke() } } }.exceptionOrNull() + assertTrue( + "Amazon subscriptionBillingIssue handler must throw FeatureNotSupported, got: $thrown", + thrown is OpenIapError.FeatureNotSupported + ) + } + + @Test + fun `add and remove SubscriptionBillingIssueListener never invokes the callback`() { + val module = OpenIapModule(ContextWrapper(null)) + val invoked = AtomicInteger(0) + val listener = OpenIapSubscriptionBillingIssueListener { invoked.incrementAndGet() } + + module.addSubscriptionBillingIssueListener(listener) + module.removeSubscriptionBillingIssueListener(listener) + + assertEquals( + "Amazon flavor must never invoke the subscriptionBillingIssue listener", + 0, + invoked.get() + ) + } +} diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionGroupMappingHorizonTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionGroupMappingHorizonTest.kt new file mode 100644 index 00000000..49c95348 --- /dev/null +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionGroupMappingHorizonTest.kt @@ -0,0 +1,43 @@ +package dev.hyo.openiap + +import dev.hyo.openiap.utils.HorizonBillingConverters.toActiveSubscription +import org.junit.Assert.assertEquals +import org.junit.Test + +class SubscriptionGroupMappingHorizonTest { + + @Test + fun `active subscriptions keep independent product ids for multiple groups`() { + val premium = purchase("dev.hyo.martie.premium.monthly", "token-premium") + .toActiveSubscription() + val pro = purchase("dev.hyo.martie.pro.monthly", "token-pro") + .toActiveSubscription() + + assertEquals("dev.hyo.martie.premium.monthly", premium.productId) + assertEquals("dev.hyo.martie.premium.monthly", premium.currentPlanId) + assertEquals("token-premium", premium.purchaseToken) + assertEquals("dev.hyo.martie.pro.monthly", pro.productId) + assertEquals("dev.hyo.martie.pro.monthly", pro.currentPlanId) + assertEquals("token-pro", pro.purchaseToken) + } + + private fun purchase(productId: String, token: String): PurchaseAndroid = PurchaseAndroid( + autoRenewingAndroid = true, + currentPlanId = productId, + dataAndroid = "{}", + id = token, + ids = listOf(productId), + isAcknowledgedAndroid = true, + isAutoRenewing = true, + packageNameAndroid = "dev.hyo.martie", + platform = IapPlatform.Android, + productId = productId, + purchaseState = PurchaseState.Purchased, + purchaseToken = token, + quantity = 1, + signatureAndroid = null, + store = IapStore.Horizon, + transactionDate = 1_700_000_000_000.0, + transactionId = token + ) +} diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionGroupMappingPlayTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionGroupMappingPlayTest.kt new file mode 100644 index 00000000..c152dc56 --- /dev/null +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionGroupMappingPlayTest.kt @@ -0,0 +1,43 @@ +package dev.hyo.openiap + +import dev.hyo.openiap.utils.toActiveSubscription +import org.junit.Assert.assertEquals +import org.junit.Test + +class SubscriptionGroupMappingPlayTest { + + @Test + fun `active subscriptions keep independent product ids for multiple groups`() { + val premium = purchase("dev.hyo.martie.premium.monthly", "token-premium") + .toActiveSubscription() + val pro = purchase("dev.hyo.martie.pro.monthly", "token-pro") + .toActiveSubscription() + + assertEquals("dev.hyo.martie.premium.monthly", premium.productId) + assertEquals("dev.hyo.martie.premium.monthly", premium.currentPlanId) + assertEquals("token-premium", premium.purchaseToken) + assertEquals("dev.hyo.martie.pro.monthly", pro.productId) + assertEquals("dev.hyo.martie.pro.monthly", pro.currentPlanId) + assertEquals("token-pro", pro.purchaseToken) + } + + private fun purchase(productId: String, token: String): PurchaseAndroid = PurchaseAndroid( + autoRenewingAndroid = true, + currentPlanId = productId, + dataAndroid = "{}", + id = token, + ids = listOf(productId), + isAcknowledgedAndroid = true, + isAutoRenewing = true, + packageNameAndroid = "dev.hyo.martie", + platform = IapPlatform.Android, + productId = productId, + purchaseState = PurchaseState.Purchased, + purchaseToken = token, + quantity = 1, + signatureAndroid = null, + store = IapStore.Google, + transactionDate = 1_700_000_000_000.0, + transactionId = token + ) +} diff --git a/packages/google/package.json b/packages/google/package.json index 035d7f12..19e3c9b7 100644 --- a/packages/google/package.json +++ b/packages/google/package.json @@ -1,6 +1,6 @@ { "name": "@hyodotdev/openiap-android", - "version": "2.2.1", + "version": "2.3.0-rc.1", "private": true, "description": "OpenIAP Android/Kotlin implementation", "scripts": { diff --git a/packages/gql/codegen/core/schema-linter.ts b/packages/gql/codegen/core/schema-linter.ts index 6423f7bf..4a7307c6 100644 --- a/packages/gql/codegen/core/schema-linter.ts +++ b/packages/gql/codegen/core/schema-linter.ts @@ -24,15 +24,40 @@ export interface LintOptions { strict?: boolean; } -const PLATFORM_TYPE_SUFFIX_EXCEPTIONS = new Set([ - // Public API names kept for source/binary compatibility. +const IOS_TYPE_SUFFIX_EXCEPTIONS = new Set([ + // StoreKit names this payload AppTransaction; keep the public OpenIAP type stable. 'AppTransaction', - 'ProductAndroidOneTimePurchaseOfferDetail', - 'ProductSubscriptionAndroidOfferDetails', +]); + +const ANDROID_TYPE_SUFFIX_EXCEPTIONS = new Set([ + // Legacy public type name from the User Choice Billing API. 'UserChoiceBillingDetails', + // Meta Horizon verification result is Android-flavor specific but keeps the + // store suffix for API clarity. 'VerifyPurchaseResultHorizon', ]); +function isAllowedPlatformTypeName( + typeName: string, + platform: 'ios' | 'android', +): boolean { + if (typeName.startsWith('Query') || typeName.startsWith('Mutation')) { + return true; + } + + if (platform === 'ios') { + return typeName.endsWith('IOS') || IOS_TYPE_SUFFIX_EXCEPTIONS.has(typeName); + } + + return ( + typeName.endsWith('Android') || + typeName.includes('Android') || + typeName.includes('Horizon') || + typeName.includes('Amazon') || + ANDROID_TYPE_SUFFIX_EXCEPTIONS.has(typeName) + ); +} + /** * Lint schema conventions and return findings. */ @@ -71,28 +96,24 @@ export function lintSchema( } // Platform suffix checks for types in platform-specific files - if (isIOSFile && !typeName.endsWith('IOS') && !typeName.startsWith('Query') && !typeName.startsWith('Mutation')) { - if (!PLATFORM_TYPE_SUFFIX_EXCEPTIONS.has(typeName)) { - results.push({ - level: 'warning', - file: fileName, - line: lineNum, - message: `Type "${typeName}" in iOS file should end with "IOS" suffix`, - rule: 'ios-type-suffix', - }); - } + if (isIOSFile && !isAllowedPlatformTypeName(typeName, 'ios')) { + results.push({ + level: 'warning', + file: fileName, + line: lineNum, + message: `Type "${typeName}" in iOS file should end with "IOS" suffix`, + rule: 'ios-type-suffix', + }); } - if (isAndroidFile && !typeName.endsWith('Android') && !typeName.startsWith('Query') && !typeName.startsWith('Mutation')) { - if (!PLATFORM_TYPE_SUFFIX_EXCEPTIONS.has(typeName)) { - results.push({ - level: 'warning', - file: fileName, - line: lineNum, - message: `Type "${typeName}" in Android file should end with "Android" suffix`, - rule: 'android-type-suffix', - }); - } + if (isAndroidFile && !isAllowedPlatformTypeName(typeName, 'android')) { + results.push({ + level: 'warning', + file: fileName, + line: lineNum, + message: `Type "${typeName}" in Android file should end with "Android" suffix`, + rule: 'android-type-suffix', + }); } continue; diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index c35b1bd3..05aab794 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -978,7 +978,8 @@ public enum IapStore Unknown, Apple, Google, - Horizon + Horizon, + Amazon } public sealed class IapStoreJsonConverter : JsonConverter @@ -997,6 +998,9 @@ public sealed class IapStoreJsonConverter : JsonConverter ["horizon"] = IapStore.Horizon, ["HORIZON"] = IapStore.Horizon, ["Horizon"] = IapStore.Horizon, + ["amazon"] = IapStore.Amazon, + ["AMAZON"] = IapStore.Amazon, + ["Amazon"] = IapStore.Amazon, }; private static readonly Dictionary _toString = new() @@ -1005,6 +1009,7 @@ public sealed class IapStoreJsonConverter : JsonConverter [IapStore.Apple] = "apple", [IapStore.Google] = "google", [IapStore.Horizon] = "horizon", + [IapStore.Amazon] = "amazon", }; public override IapStore Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -3846,7 +3851,8 @@ public sealed record RequestPurchaseProps /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) public sealed record RequestPurchasePropsByPlatforms { @@ -3943,7 +3949,8 @@ public sealed record RequestSubscriptionIosProps /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) public sealed record RequestSubscriptionPropsByPlatforms { @@ -3961,6 +3968,19 @@ public sealed record RequestSubscriptionPropsByPlatforms public RequestSubscriptionAndroidProps? Android { get; init; } } +public sealed record RequestVerifyPurchaseWithIapkitAmazonProps +{ + /// Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + [JsonPropertyName("userId")] + public string? UserId { get; init; } + /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + [JsonPropertyName("receiptId")] + public required string ReceiptId { get; init; } + /// Use Amazon RVS Cloud Sandbox for App Tester receipts. + [JsonPropertyName("sandbox")] + public bool? Sandbox { get; init; } +} + public sealed record RequestVerifyPurchaseWithIapkitAppleProps { /// The JWS token returned with the purchase response. @@ -3979,6 +3999,7 @@ public sealed record RequestVerifyPurchaseWithIapkitGoogleProps /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) public sealed record RequestVerifyPurchaseWithIapkitProps { /// API key used for the Authorization header (Bearer {apiKey}). @@ -3990,6 +4011,9 @@ public sealed record RequestVerifyPurchaseWithIapkitProps /// Google Play Store verification parameters. [JsonPropertyName("google")] public RequestVerifyPurchaseWithIapkitGoogleProps? Google { get; init; } + /// Amazon Appstore verification parameters. + [JsonPropertyName("amazon")] + public RequestVerifyPurchaseWithIapkitAmazonProps? Amazon { get; init; } } /// Product-level subscription replacement parameters (Android) diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index be4d7f6d..3251b5c8 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -627,7 +627,8 @@ public enum class IapStore(val rawValue: String) { Unknown("unknown"), Apple("apple"), Google("google"), - Horizon("horizon") + Horizon("horizon"), + Amazon("amazon") companion object { fun fromJson(value: String): IapStore = when (value) { @@ -643,6 +644,9 @@ public enum class IapStore(val rawValue: String) { "horizon" -> IapStore.Horizon "HORIZON" -> IapStore.Horizon "Horizon" -> IapStore.Horizon + "amazon" -> IapStore.Amazon + "AMAZON" -> IapStore.Amazon + "Amazon" -> IapStore.Amazon else -> throw IllegalArgumentException("Unknown IapStore value: $value") } } @@ -4619,7 +4623,8 @@ public data class RequestPurchaseProps( * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ public data class RequestPurchasePropsByPlatforms( @@ -4823,7 +4828,8 @@ public data class RequestSubscriptionIosProps( * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ public data class RequestSubscriptionPropsByPlatforms( @@ -4863,6 +4869,41 @@ public data class RequestSubscriptionPropsByPlatforms( ) } +public data class RequestVerifyPurchaseWithIapkitAmazonProps( + /** + * Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + */ + val receiptId: String, + /** + * Use Amazon RVS Cloud Sandbox for App Tester receipts. + */ + val sandbox: Boolean? = null, + /** + * Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + */ + val userId: String? = null +) { + companion object { + fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitAmazonProps? { + val receiptId = json["receiptId"] as? String + val sandbox = json["sandbox"] as? Boolean + val userId = json["userId"] as? String + if (receiptId == null) return null + return RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId = receiptId, + sandbox = sandbox, + userId = userId, + ) + } + } + + fun toJson(): Map = mapOf( + "receiptId" to receiptId, + "sandbox" to sandbox, + "userId" to userId, + ) +} + public data class RequestVerifyPurchaseWithIapkitAppleProps( /** * The JWS token returned with the purchase response. @@ -4910,8 +4951,13 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps( * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) */ public data class RequestVerifyPurchaseWithIapkitProps( + /** + * Amazon Appstore verification parameters. + */ + val amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = null, /** * API key used for the Authorization header (Bearer {apiKey}). */ @@ -4928,6 +4974,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitProps { return RequestVerifyPurchaseWithIapkitProps( + amazon = (json["amazon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(it) }, apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, @@ -4936,6 +4983,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( } fun toJson(): Map = mapOf( + "amazon" to amazon?.toJson(), "apiKey" to apiKey, "apple" to apple?.toJson(), "google" to google?.toJson(), diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 0199cfc7..cecc3c52 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -303,6 +303,7 @@ public enum IapStore: String, Codable, CaseIterable { case apple = "apple" case google = "google" case horizon = "horizon" + case amazon = "amazon" } /// Payment mode for subscription offers. @@ -1847,7 +1848,8 @@ public struct RequestPurchaseProps: Codable { /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) public struct RequestPurchasePropsByPlatforms: Codable { /// @deprecated Use google instead @@ -1975,7 +1977,8 @@ public struct RequestSubscriptionIosProps: Codable { /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) public struct RequestSubscriptionPropsByPlatforms: Codable { /// @deprecated Use google instead @@ -2000,6 +2003,25 @@ public struct RequestSubscriptionPropsByPlatforms: Codable { } } +public struct RequestVerifyPurchaseWithIapkitAmazonProps: Codable { + /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + public var receiptId: String + /// Use Amazon RVS Cloud Sandbox for App Tester receipts. + public var sandbox: Bool? + /// Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + public var userId: String? + + public init( + receiptId: String, + sandbox: Bool? = nil, + userId: String? = nil + ) { + self.receiptId = receiptId + self.sandbox = sandbox + self.userId = userId + } +} + public struct RequestVerifyPurchaseWithIapkitAppleProps: Codable { /// The JWS token returned with the purchase response. public var jws: String @@ -2026,7 +2048,10 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) public struct RequestVerifyPurchaseWithIapkitProps: Codable { + /// Amazon Appstore verification parameters. + public var amazon: RequestVerifyPurchaseWithIapkitAmazonProps? /// API key used for the Authorization header (Bearer {apiKey}). public var apiKey: String? /// Apple App Store verification parameters. @@ -2035,10 +2060,12 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { public var google: RequestVerifyPurchaseWithIapkitGoogleProps? public init( + amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = nil, apiKey: String? = nil, apple: RequestVerifyPurchaseWithIapkitAppleProps? = nil, google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil ) { + self.amazon = amazon self.apiKey = apiKey self.apple = apple self.google = google diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 9723c028..1f93a65f 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -534,7 +534,8 @@ enum IapStore { Unknown('unknown'), Apple('apple'), Google('google'), - Horizon('horizon'); + Horizon('horizon'), + Amazon('amazon'); const IapStore(this.value); final String value; @@ -550,6 +551,8 @@ enum IapStore { return IapStore.Google; case 'horizon': return IapStore.Horizon; + case 'amazon': + return IapStore.Amazon; } throw ArgumentError('Unknown IapStore value: $value'); } @@ -4540,7 +4543,8 @@ class _SubsPurchase extends RequestPurchaseProps { /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) class RequestPurchasePropsByPlatforms { const RequestPurchasePropsByPlatforms({ @@ -4717,7 +4721,8 @@ class RequestSubscriptionIosProps { /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store -/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// - google: Targets Play Store by default, Horizon when built with horizon flavor, +/// or Fire OS when built with amazon flavor /// (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms { const RequestSubscriptionPropsByPlatforms({ @@ -4755,6 +4760,37 @@ class RequestSubscriptionPropsByPlatforms { } } +class RequestVerifyPurchaseWithIapkitAmazonProps { + const RequestVerifyPurchaseWithIapkitAmazonProps({ + required this.receiptId, + this.sandbox, + this.userId, + }); + + /// Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + final String receiptId; + /// Use Amazon RVS Cloud Sandbox for App Tester receipts. + final bool? sandbox; + /// Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + final String? userId; + + factory RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(Map json) { + return RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId: json['receiptId'] as String, + sandbox: json['sandbox'] as bool?, + userId: json['userId'] as String?, + ); + } + + Map toJson() { + return { + 'receiptId': receiptId, + 'sandbox': sandbox, + 'userId': userId, + }; + } +} + class RequestVerifyPurchaseWithIapkitAppleProps { const RequestVerifyPurchaseWithIapkitAppleProps({ required this.jws, @@ -4801,13 +4837,17 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { /// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) +/// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) class RequestVerifyPurchaseWithIapkitProps { const RequestVerifyPurchaseWithIapkitProps({ + this.amazon, this.apiKey, this.apple, this.google, }); + /// Amazon Appstore verification parameters. + final RequestVerifyPurchaseWithIapkitAmazonProps? amazon; /// API key used for the Authorization header (Bearer {apiKey}). final String? apiKey; /// Apple App Store verification parameters. @@ -4817,6 +4857,7 @@ class RequestVerifyPurchaseWithIapkitProps { factory RequestVerifyPurchaseWithIapkitProps.fromJson(Map json) { return RequestVerifyPurchaseWithIapkitProps( + amazon: json['amazon'] != null ? RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(json['amazon'] as Map) : null, apiKey: json['apiKey'] as String?, apple: json['apple'] != null ? RequestVerifyPurchaseWithIapkitAppleProps.fromJson(json['apple'] as Map) : null, google: json['google'] != null ? RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(json['google'] as Map) : null, @@ -4825,6 +4866,7 @@ class RequestVerifyPurchaseWithIapkitProps { Map toJson() { return { + 'amazon': amazon?.toJson(), 'apiKey': apiKey, 'apple': apple?.toJson(), 'google': google?.toJson(), diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 76bc09e7..802cf309 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -182,6 +182,7 @@ enum IapStore { APPLE = 1, GOOGLE = 2, HORIZON = 3, + AMAZON = 4, } ## Payment mode for subscription offers. Determines how the user pays during the offer period. @@ -4029,7 +4030,7 @@ class RequestPurchaseProps: dict["useAlternativeBilling"] = use_alternative_billing return dict -## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, or Horizon when built with horizon flavor (determined at build time, not runtime) +## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestPurchasePropsByPlatforms: ## Apple-specific purchase parameters var apple: RequestPurchaseIosProps @@ -4254,7 +4255,7 @@ class RequestSubscriptionIosProps: dict["advancedCommerceData"] = advanced_commerce_data return dict -## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, or Horizon when built with horizon flavor (determined at build time, not runtime) +## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms: ## Apple-specific subscription parameters var apple: RequestSubscriptionIosProps @@ -4313,6 +4314,34 @@ class RequestSubscriptionPropsByPlatforms: dict["android"] = android return dict +class RequestVerifyPurchaseWithIapkitAmazonProps: + ## Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + var user_id: Variant = null + ## Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + var receipt_id: String = "" + ## Use Amazon RVS Cloud Sandbox for App Tester receipts. + var sandbox: Variant = null + + static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitAmazonProps: + var obj = RequestVerifyPurchaseWithIapkitAmazonProps.new() + if data.has("userId") and data["userId"] != null: + obj.user_id = data["userId"] + if data.has("receiptId") and data["receiptId"] != null: + obj.receipt_id = data["receiptId"] + if data.has("sandbox") and data["sandbox"] != null: + obj.sandbox = data["sandbox"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if user_id != null: + dict["userId"] = user_id + if receipt_id != null: + dict["receiptId"] = receipt_id + if sandbox != null: + dict["sandbox"] = sandbox + return dict + class RequestVerifyPurchaseWithIapkitAppleProps: ## The JWS token returned with the purchase response. var jws: String = "" @@ -4345,7 +4374,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: dict["purchaseToken"] = purchase_token return dict -## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) +## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null @@ -4353,6 +4382,8 @@ class RequestVerifyPurchaseWithIapkitProps: var apple: RequestVerifyPurchaseWithIapkitAppleProps ## Google Play Store verification parameters. var google: RequestVerifyPurchaseWithIapkitGoogleProps + ## Amazon Appstore verification parameters. + var amazon: RequestVerifyPurchaseWithIapkitAmazonProps static func from_dict(data: Dictionary) -> RequestVerifyPurchaseWithIapkitProps: var obj = RequestVerifyPurchaseWithIapkitProps.new() @@ -4368,6 +4399,11 @@ class RequestVerifyPurchaseWithIapkitProps: obj.google = RequestVerifyPurchaseWithIapkitGoogleProps.from_dict(data["google"]) else: obj.google = data["google"] + if data.has("amazon") and data["amazon"] != null: + if data["amazon"] is Dictionary: + obj.amazon = RequestVerifyPurchaseWithIapkitAmazonProps.from_dict(data["amazon"]) + else: + obj.amazon = data["amazon"] return obj func to_dict() -> Dictionary: @@ -4384,6 +4420,11 @@ class RequestVerifyPurchaseWithIapkitProps: dict["google"] = google.to_dict() else: dict["google"] = google + if amazon != null: + if amazon.has_method("to_dict"): + dict["amazon"] = amazon.to_dict() + else: + dict["amazon"] = amazon return dict ## Product-level subscription replacement parameters (Android) Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams Available in Google Play Billing Library 8.1.0+ @@ -4728,7 +4769,8 @@ const IAP_STORE_VALUES = { IapStore.UNKNOWN: "unknown", IapStore.APPLE: "apple", IapStore.GOOGLE: "google", - IapStore.HORIZON: "horizon" + IapStore.HORIZON: "horizon", + IapStore.AMAZON: "amazon" } const PAYMENT_MODE_VALUES = { @@ -4997,7 +5039,8 @@ const IAP_STORE_FROM_STRING = { "unknown": IapStore.UNKNOWN, "apple": IapStore.APPLE, "google": IapStore.GOOGLE, - "horizon": IapStore.HORIZON + "horizon": IapStore.HORIZON, + "amazon": IapStore.AMAZON } const PAYMENT_MODE_FROM_STRING = { diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 518f5ffd..2e3ddc63 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -511,7 +511,7 @@ export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product export type IapPlatform = 'ios' | 'android'; -export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon'; +export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon' | 'amazon'; /** Unified purchase states from IAPKit verification response. */ export type IapkitPurchaseState = 'entitled' | 'pending-acknowledgment' | 'pending' | 'canceled' | 'expired' | 'ready-to-consume' | 'consumed' | 'unknown' | 'inauthentic'; @@ -1585,7 +1585,8 @@ export type RequestPurchaseProps = * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ export interface RequestPurchasePropsByPlatforms { @@ -1679,7 +1680,8 @@ export interface RequestSubscriptionIosProps { * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. * - apple: Always targets App Store - * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * - google: Targets Play Store by default, Horizon when built with horizon flavor, + * or Fire OS when built with amazon flavor * (determined at build time, not runtime) */ export interface RequestSubscriptionPropsByPlatforms { @@ -1693,6 +1695,15 @@ export interface RequestSubscriptionPropsByPlatforms { ios?: (RequestSubscriptionIosProps | null); } +export interface RequestVerifyPurchaseWithIapkitAmazonProps { + /** Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). */ + receiptId: string; + /** Use Amazon RVS Cloud Sandbox for App Tester receipts. */ + sandbox?: (boolean | null); + /** Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). */ + userId?: (string | null); +} + export interface RequestVerifyPurchaseWithIapkitAppleProps { /** The JWS token returned with the purchase response. */ jws: string; @@ -1708,8 +1719,11 @@ export interface RequestVerifyPurchaseWithIapkitGoogleProps { * * - apple: Verifies via App Store (JWS token) * - google: Verifies via Play Store (purchase token) + * - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) */ export interface RequestVerifyPurchaseWithIapkitProps { + /** Amazon Appstore verification parameters. */ + amazon?: (RequestVerifyPurchaseWithIapkitAmazonProps | null); /** API key used for the Authorization header (Bearer {apiKey}). */ apiKey?: (string | null); /** Apple App Store verification parameters. */ diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 1bc2f085..e7f9cb59 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -49,6 +49,7 @@ enum IapStore { Apple Google Horizon + Amazon } # Common product fields @@ -219,7 +220,8 @@ Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store -- google: Targets Play Store by default, or Horizon when built with horizon flavor +- google: Targets Play Store by default, Horizon when built with horizon flavor, + or Fire OS when built with amazon flavor (determined at build time, not runtime) """ input RequestPurchasePropsByPlatforms { @@ -246,7 +248,8 @@ Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store -- google: Targets Play Store by default, or Horizon when built with horizon flavor +- google: Targets Play Store by default, Horizon when built with horizon flavor, + or Fire OS when built with amazon flavor (determined at build time, not runtime) """ input RequestSubscriptionPropsByPlatforms { @@ -292,7 +295,7 @@ input VerifyPurchaseProps { } union VerifyPurchaseResult = - VerifyPurchaseResultAndroid + | VerifyPurchaseResultAndroid | VerifyPurchaseResultIOS | VerifyPurchaseResultHorizon @@ -310,11 +313,27 @@ input RequestVerifyPurchaseWithIapkitGoogleProps { purchaseToken: String! } +input RequestVerifyPurchaseWithIapkitAmazonProps { + """ + Amazon Appstore user id returned by PurchaseResponse.getUserData().getUserId(). + """ + userId: String + """ + Amazon Appstore receipt id returned by PurchaseResponse.getReceipt().getReceiptId(). + """ + receiptId: String! + """ + Use Amazon RVS Cloud Sandbox for App Tester receipts. + """ + sandbox: Boolean +} + """ Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) +- amazon: Verifies via Amazon Appstore RVS (userId + receiptId) """ input RequestVerifyPurchaseWithIapkitProps { """ @@ -329,6 +348,10 @@ input RequestVerifyPurchaseWithIapkitProps { Google Play Store verification parameters. """ google: RequestVerifyPurchaseWithIapkitGoogleProps + """ + Amazon Appstore verification parameters. + """ + amazon: RequestVerifyPurchaseWithIapkitAmazonProps } """ diff --git a/packages/kit/Dockerfile b/packages/kit/Dockerfile index 536f90e8..fe874324 100644 --- a/packages/kit/Dockerfile +++ b/packages/kit/Dockerfile @@ -75,6 +75,8 @@ COPY --from=builder --chown=openiap:openiap /app/packages/kit/dist ./public ENV STATIC_ROOT=/srv/public ENV PORT=3000 +ENV NODE_ENV=production +ENV APP_ENV=production EXPOSE 3000/tcp USER openiap diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index cf45d8fb..f8fa0276 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -41,6 +41,7 @@ import type * as projects_mutation from "../projects/mutation.js"; import type * as projects_query from "../projects/query.js"; import type * as projects_setupStatus from "../projects/setupStatus.js"; import type * as purchases_action from "../purchases/action.js"; +import type * as purchases_amazon from "../purchases/amazon.js"; import type * as purchases_android from "../purchases/android.js"; import type * as purchases_cleanup from "../purchases/cleanup.js"; import type * as purchases_errors from "../purchases/errors.js"; @@ -121,6 +122,7 @@ declare const fullApi: ApiFromModules<{ "projects/query": typeof projects_query; "projects/setupStatus": typeof projects_setupStatus; "purchases/action": typeof purchases_action; + "purchases/amazon": typeof purchases_amazon; "purchases/android": typeof purchases_android; "purchases/cleanup": typeof purchases_cleanup; "purchases/errors": typeof purchases_errors; diff --git a/packages/kit/convex/analytics/action.ts b/packages/kit/convex/analytics/action.ts index 09064390..44fef10f 100644 --- a/packages/kit/convex/analytics/action.ts +++ b/packages/kit/convex/analytics/action.ts @@ -38,6 +38,7 @@ export const trackFirstReceiptVerified = internalAction({ v.literal("apple"), v.literal("google"), v.literal("horizon"), + v.literal("amazon"), ), }, handler: async (_ctx, args) => { diff --git a/packages/kit/convex/projects/mutation.ts b/packages/kit/convex/projects/mutation.ts index f370b19a..34d188fa 100644 --- a/packages/kit/convex/projects/mutation.ts +++ b/packages/kit/convex/projects/mutation.ts @@ -155,6 +155,27 @@ function normalizeHorizonAppSecret(input: string): string { return normalized; } +function normalizeAmazonSharedSecret(input: string): string { + const normalized = input.trim(); + if (!normalized) { + throw createError( + ErrorCode.INVALID_INPUT, + "Amazon RVS shared secret cannot be empty.", + ); + } + // Amazon shared secrets are opaque strings copied from the Amazon + // Developer Console and may contain punctuation such as ':' and '='. + // Validate only a sane size envelope so we don't reject legitimate + // production secrets or sandbox placeholders. + if (normalized.length > 2_048) { + throw createError( + ErrorCode.INVALID_INPUT, + "Amazon RVS shared secret looks malformed (expected 1–2048 characters).", + ); + } + return normalized; +} + // Helper to generate URL-friendly slug function generateSlug(name: string): string { return name @@ -280,6 +301,7 @@ export const updateProject = mutation({ horizonEnabled: v.optional(v.boolean()), horizonAppId: v.optional(v.string()), horizonAppSecret: v.optional(v.string()), + amazonSharedSecret: v.optional(v.union(v.string(), v.null())), reportingCurrency: v.optional(v.string()), }, handler: async (ctx, args) => { @@ -370,6 +392,12 @@ export const updateProject = mutation({ args.horizonAppSecret, ); } + if (args.amazonSharedSecret !== undefined) { + updates.amazonSharedSecret = + args.amazonSharedSecret === null + ? null + : normalizeAmazonSharedSecret(args.amazonSharedSecret); + } // Invariant: enabling Horizon without both credentials leaves the // project in a state where verify calls would throw diff --git a/packages/kit/convex/projects/query.ts b/packages/kit/convex/projects/query.ts index 81df17d4..56005beb 100644 --- a/packages/kit/convex/projects/query.ts +++ b/packages/kit/convex/projects/query.ts @@ -10,23 +10,27 @@ import { function projectWithSecretState(project: Doc<"projects">): Omit< Doc<"projects">, - "horizonAppSecret" + "horizonAppSecret" | "amazonSharedSecret" > & { hasHorizonAppSecret: boolean; + hasAmazonSharedSecret: boolean; } { - const { horizonAppSecret, ...rest } = project; + const { horizonAppSecret, amazonSharedSecret, ...rest } = project; return { ...rest, hasHorizonAppSecret: typeof horizonAppSecret === "string" && horizonAppSecret.length > 0, + hasAmazonSharedSecret: + typeof amazonSharedSecret === "string" && amazonSharedSecret.length > 0, }; } function projectForApiKeyLookup(project: Doc<"projects">): Omit< Doc<"projects">, - "apiKey" | "horizonAppSecret" + "apiKey" | "horizonAppSecret" | "amazonSharedSecret" > & { hasHorizonAppSecret: boolean; + hasAmazonSharedSecret: boolean; } { const { apiKey, ...rest } = projectWithSecretState(project); void apiKey; @@ -35,9 +39,10 @@ function projectForApiKeyLookup(project: Doc<"projects">): Omit< function projectForDashboard(project: Doc<"projects">): Omit< Doc<"projects">, - "apiKey" | "horizonAppSecret" + "apiKey" | "horizonAppSecret" | "amazonSharedSecret" > & { hasHorizonAppSecret: boolean; + hasAmazonSharedSecret: boolean; } { return projectForApiKeyLookup(project); } @@ -46,9 +51,13 @@ function projectForList( project: Doc<"projects">, projectIdsWithAnyKey: Set, projectIdsWithActiveKey: Set, -): Omit, "apiKey" | "horizonAppSecret"> & { +): Omit< + Doc<"projects">, + "apiKey" | "horizonAppSecret" | "amazonSharedSecret" +> & { hasApiKey: boolean; hasHorizonAppSecret: boolean; + hasAmazonSharedSecret: boolean; } { const { apiKey, ...rest } = projectWithSecretState(project); return { diff --git a/packages/kit/convex/projects/setupStatus.ts b/packages/kit/convex/projects/setupStatus.ts index 6334f817..4bcd9a55 100644 --- a/packages/kit/convex/projects/setupStatus.ts +++ b/packages/kit/convex/projects/setupStatus.ts @@ -30,6 +30,7 @@ export const getSetupStatus = query({ ios: platformShape, android: platformShape, horizon: platformShape, + amazon: platformShape, appleP8Uploaded: v.boolean(), googleServiceAccountUploaded: v.boolean(), }), @@ -49,6 +50,7 @@ export const getSetupStatus = query({ ios: empty, android: empty, horizon: empty, + amazon: empty, appleP8Uploaded: false, googleServiceAccountUploaded: false, }; @@ -78,6 +80,11 @@ export const getSetupStatus = query({ if (!project.horizonAppId) horizonMissing.push("horizonAppId"); if (!project.horizonAppSecret) horizonMissing.push("horizonAppSecret"); + const amazonMissing: string[] = []; + if (!project.amazonSharedSecret) { + amazonMissing.push("amazonSharedSecret"); + } + return { found: true, projectId: project._id, @@ -93,6 +100,10 @@ export const getSetupStatus = query({ configured: horizonMissing.length === 0, missing: horizonMissing, }, + amazon: { + configured: amazonMissing.length === 0, + missing: amazonMissing, + }, // The webhook receivers ALSO need the .p8 / service-account JSON // file uploaded to the project; check the `files` table directly // so the setup card reflects what the operator has actually diff --git a/packages/kit/convex/purchases/action.ts b/packages/kit/convex/purchases/action.ts index e8718367..b94860ed 100644 --- a/packages/kit/convex/purchases/action.ts +++ b/packages/kit/convex/purchases/action.ts @@ -1,7 +1,9 @@ "use node"; import { verifyGooglePlayReceiptInternalV1 } from "./android"; +import { verifyAmazonReceiptInternalV1 } from "./amazon"; import { verifyAppStoreReceiptInternalV1 } from "./ios"; export const verifyGooglePlayReceipt = verifyGooglePlayReceiptInternalV1; export const verifyAppStoreReceipt = verifyAppStoreReceiptInternalV1; +export const verifyAmazonReceipt = verifyAmazonReceiptInternalV1; diff --git a/packages/kit/convex/purchases/amazon.test.ts b/packages/kit/convex/purchases/amazon.test.ts new file mode 100644 index 00000000..b8081cef --- /dev/null +++ b/packages/kit/convex/purchases/amazon.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "vitest"; + +import { + buildAmazonRemoteId, + mapAmazonReceiptState, + parseAmazonReceiptResponse, +} from "./amazon"; +import { AmazonReceiptVerificationError } from "./errors"; +import { HarmonizedPurchaseState } from "./purchaseState"; + +describe("buildAmazonRemoteId", () => { + test("separates sandbox and production receipts", () => { + expect( + buildAmazonRemoteId({ + userId: "user/one", + receiptId: "receipt:one", + sandbox: true, + }), + ).toBe("sandbox:user%2Fone:receipt%3Aone"); + + expect( + buildAmazonRemoteId({ + userId: "user/one", + receiptId: "receipt:one", + sandbox: false, + }), + ).toBe("production:user%2Fone:receipt%3Aone"); + }); +}); + +describe("mapAmazonReceiptState", () => { + test("maps canceled receipts before product type handling", () => { + expect( + mapAmazonReceiptState({ + cancelDate: 1_700_000_000_000, + productType: "CONSUMABLE", + }), + ).toBe(HarmonizedPurchaseState.CANCELED); + }); + + test("maps Amazon product types to harmonized states", () => { + expect(mapAmazonReceiptState({ productType: "CONSUMABLE" })).toBe( + HarmonizedPurchaseState.READY_TO_CONSUME, + ); + expect(mapAmazonReceiptState({ productType: "ENTITLED" })).toBe( + HarmonizedPurchaseState.ENTITLED, + ); + expect(mapAmazonReceiptState({ productType: "SUBSCRIPTION" })).toBe( + HarmonizedPurchaseState.ENTITLED, + ); + }); + + test("does not treat Amazon subscription renewalDate as expiry", () => { + expect( + mapAmazonReceiptState({ + productType: "SUBSCRIPTION", + renewalDate: 1_000, + }), + ).toBe(HarmonizedPurchaseState.ENTITLED); + }); + + test("falls back to unknown for unrecognized product types", () => { + expect(mapAmazonReceiptState({ productType: "FUTURE_KIND" })).toBe( + HarmonizedPurchaseState.UNKNOWN, + ); + }); +}); + +describe("parseAmazonReceiptResponse", () => { + test("accepts object responses from RVS", () => { + const raw = { + productId: "dev.hyo.martie.premium", + productType: "SUBSCRIPTION", + }; + + expect(parseAmazonReceiptResponse(raw)).toBe(raw); + }); + + test("rejects non-object RVS responses", () => { + expect(() => parseAmazonReceiptResponse(null)).toThrow( + AmazonReceiptVerificationError, + ); + expect(() => parseAmazonReceiptResponse("not-json")).toThrow( + AmazonReceiptVerificationError, + ); + }); +}); diff --git a/packages/kit/convex/purchases/amazon.ts b/packages/kit/convex/purchases/amazon.ts new file mode 100644 index 00000000..62ed57e6 --- /dev/null +++ b/packages/kit/convex/purchases/amazon.ts @@ -0,0 +1,329 @@ +"use node"; + +import { v } from "convex/values"; + +import { internal } from "../_generated/api"; +import { action } from "../_generated/server"; +import { + AmazonReceiptInvalidError, + AmazonReceiptVerificationError, + AmazonSharedSecretNotConfiguredError, + ReceiptVerificationError, +} from "./errors"; +import { HarmonizedPurchaseState } from "./purchaseState"; +import { + extractHttpStatus, + isTransientHttpError, + retryOnTransient, +} from "./retry"; +import { + getProjectByApiKey, + isValidState, + receiptResponseValidator, +} from "./shared"; + +const AMAZON_RVS_BASE_URL = "https://appstore-sdk.amazon.com"; +const AMAZON_RVS_VERSION = "1.0"; +const AMAZON_SANDBOX_SHARED_SECRET = "iapkit-sandbox"; +const AMAZON_RVS_FETCH_TIMEOUT_MS = 10_000; + +export interface AmazonReceiptData { + autoRenewing?: boolean; + cancelDate?: number | null; + cancelReason?: number | null; + gracePeriodEndDate?: number | null; + productId?: string; + productType?: string; + purchaseDate?: number; + quantity?: number | null; + receiptId?: string; + renewalDate?: number | null; + term?: string | null; + termSku?: string | null; + testTransaction?: boolean; +} + +function describeError(error: unknown): string { + if (error instanceof ReceiptVerificationError) { + return error.errorMessage; + } + const status = (error as { code?: unknown })?.code; + const type = error instanceof Error ? error.name : typeof error; + return typeof status === "number" ? `${type} ${status}` : type; +} + +function isAbortError(error: unknown): boolean { + return ( + error !== null && + typeof error === "object" && + (error as { name?: unknown }).name === "AbortError" + ); +} + +function encodePathSegment(value: string): string { + return encodeURIComponent(value); +} + +function buildAmazonRvsUrl(args: { + sharedSecret: string; + userId: string; + receiptId: string; + sandbox: boolean; +}): string { + const sandboxSegment = args.sandbox ? "/sandbox" : ""; + return ( + `${AMAZON_RVS_BASE_URL}${sandboxSegment}/version/${AMAZON_RVS_VERSION}` + + `/verifyReceiptId/developer/${encodePathSegment(args.sharedSecret)}` + + `/user/${encodePathSegment(args.userId)}` + + `/receiptId/${encodePathSegment(args.receiptId)}` + ); +} + +export function buildAmazonRemoteId(args: { + userId: string; + receiptId: string; + sandbox: boolean; +}): string { + return [ + args.sandbox ? "sandbox" : "production", + encodePathSegment(args.userId), + encodePathSegment(args.receiptId), + ].join(":"); +} + +export function mapAmazonReceiptState( + receipt: AmazonReceiptData, +): HarmonizedPurchaseState { + if (receipt.cancelDate !== undefined && receipt.cancelDate !== null) { + return HarmonizedPurchaseState.CANCELED; + } + + const productType = receipt.productType?.toUpperCase(); + switch (productType) { + case "CONSUMABLE": + return HarmonizedPurchaseState.READY_TO_CONSUME; + case "ENTITLED": + return HarmonizedPurchaseState.ENTITLED; + case "SUBSCRIPTION": + return HarmonizedPurchaseState.ENTITLED; + default: + return HarmonizedPurchaseState.UNKNOWN; + } +} + +export function parseAmazonReceiptResponse(raw: unknown): AmazonReceiptData { + if (!raw || typeof raw !== "object") { + throw new AmazonReceiptVerificationError( + "Amazon RVS returned an unparseable body.", + ); + } + + return raw; +} + +function parseAmazonJsonBody(bodyText: string): unknown { + const trimmed = bodyText.trim(); + if (!trimmed) { + throw new AmazonReceiptVerificationError( + "Amazon RVS returned an empty body.", + { responseBody: "" }, + ); + } + + try { + return JSON.parse(trimmed) as unknown; + } catch (error) { + throw new AmazonReceiptVerificationError( + "Amazon RVS returned invalid JSON.", + { + parseError: + error instanceof Error ? error.message : describeError(error), + responseBody: bodyText.slice(0, 2_048), + }, + ); + } +} + +export const verifyAmazonReceiptInternalV1 = action({ + args: { + apiKey: v.string(), + userId: v.string(), + receiptId: v.string(), + sandbox: v.optional(v.boolean()), + requestIp: v.optional(v.string()), + }, + returns: receiptResponseValidator, + handler: async (ctx, args) => { + const verificationStart = Date.now(); + const project = await getProjectByApiKey(ctx, args.apiKey); + const sandbox = args.sandbox === true; + const sharedSecret = project.amazonSharedSecret?.trim(); + + if (!sandbox && !sharedSecret) { + throw new AmazonSharedSecretNotConfiguredError(); + } + + const requestData = { + store: "amazon" as const, + userId: args.userId, + receiptId: args.receiptId, + sandbox, + }; + const applicationId = project.androidPackageName ?? `amazon:${project._id}`; + const remoteId = buildAmazonRemoteId({ + userId: args.userId, + receiptId: args.receiptId, + sandbox, + }); + const url = buildAmazonRvsUrl({ + sharedSecret: sharedSecret || AMAZON_SANDBOX_SHARED_SECRET, + userId: args.userId, + receiptId: args.receiptId, + sandbox, + }); + + const saveFailedReceipt = async (failure: { + error: string; + message: string; + details?: unknown; + state?: HarmonizedPurchaseState; + }) => { + await ctx.runMutation(internal.purchases.internal.saveReceiptInternal, { + projectId: project._id, + store: "amazon", + applicationId, + remoteId, + requestData, + remoteResponse: JSON.stringify({ + error: failure.error, + message: failure.message, + details: failure.details ?? null, + }), + state: failure.state ?? HarmonizedPurchaseState.UNKNOWN, + isValid: false, + requestIp: args.requestIp, + verificationDurationMs: Date.now() - verificationStart, + }); + }; + + let parsedBody: unknown; + try { + parsedBody = await retryOnTransient( + async () => { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + AMAZON_RVS_FETCH_TIMEOUT_MS, + ); + const res = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + const bodyText = await res.text().catch(() => ""); + + if (res.status === 400 || res.status === 497) { + throw new AmazonReceiptInvalidError( + res.status, + bodyText.slice(0, 512) || + (res.status === 497 ? "invalid user ID" : "invalid receipt"), + ); + } + if (res.status === 410) { + throw new AmazonReceiptInvalidError( + res.status, + bodyText.slice(0, 512) || "receipt is no longer valid", + ); + } + if (res.status === 496) { + throw new AmazonReceiptVerificationError("invalid shared secret"); + } + if (!res.ok) { + const err = new Error( + `Amazon RVS ${res.status}: ${bodyText.slice(0, 512)}`, + ); + (err as { code?: number }).code = res.status; + throw err; + } + + return parseAmazonJsonBody(bodyText); + }, + { + shouldRetry: (error) => + isAbortError(error) || + extractHttpStatus(error) === 429 || + isTransientHttpError(error), + }, + ); + } catch (error) { + if (error instanceof AmazonReceiptInvalidError) { + const state = + error.errorDetails?.status === 410 + ? HarmonizedPurchaseState.CANCELED + : HarmonizedPurchaseState.INAUTHENTIC; + await saveFailedReceipt({ + error: error.errorCode, + message: error.errorMessage, + details: error.errorDetails ?? null, + state, + }); + return { isValid: false, state }; + } + + const message = describeError(error); + await saveFailedReceipt({ + error: + error instanceof ReceiptVerificationError + ? error.errorCode + : "AMAZON_RECEIPT_VERIFICATION_ERROR", + message, + details: + error instanceof ReceiptVerificationError + ? error.errorDetails + : undefined, + }); + throw new AmazonReceiptVerificationError(message); + } + + let receiptData: AmazonReceiptData; + let state: HarmonizedPurchaseState; + let remoteResponse: string; + try { + receiptData = parseAmazonReceiptResponse(parsedBody); + state = mapAmazonReceiptState(receiptData); + remoteResponse = JSON.stringify(receiptData); + } catch (error) { + const message = describeError(error); + await saveFailedReceipt({ + error: "AMAZON_RECEIPT_PARSE_ERROR", + message, + details: { + rawResponse: parsedBody, + stack: error instanceof Error ? error.stack : undefined, + }, + }); + throw new AmazonReceiptVerificationError(message); + } + + await ctx.runMutation(internal.purchases.internal.saveReceiptInternal, { + projectId: project._id, + store: "amazon", + applicationId, + remoteId, + requestData, + remoteResponse, + state, + isValid: isValidState(state), + requestIp: args.requestIp, + verificationDurationMs: Date.now() - verificationStart, + }); + + return { + isValid: isValidState(state), + state, + ...(receiptData.productId ? { productId: receiptData.productId } : {}), + }; + }, +}); + +export { ReceiptVerificationError }; diff --git a/packages/kit/convex/purchases/errors.ts b/packages/kit/convex/purchases/errors.ts index 594c5d14..11b657dc 100644 --- a/packages/kit/convex/purchases/errors.ts +++ b/packages/kit/convex/purchases/errors.ts @@ -66,6 +66,35 @@ export class MetaHorizonVerificationError extends ReceiptVerificationError { } } +export class AmazonSharedSecretNotConfiguredError extends ReceiptVerificationError { + constructor() { + super( + "AMAZON_SHARED_SECRET_NOT_CONFIGURED", + "Amazon RVS shared secret is not set for this project. Configure it in project settings before verifying production Amazon receipts.", + ); + } +} + +export class AmazonReceiptInvalidError extends ReceiptVerificationError { + constructor(status: number, detail: string) { + super( + "AMAZON_RECEIPT_INVALID", + `Amazon RVS rejected the receipt: ${detail}`, + { status }, + ); + } +} + +export class AmazonReceiptVerificationError extends ReceiptVerificationError { + constructor(detail: string, details?: Record) { + super( + "AMAZON_RECEIPT_VERIFICATION_ERROR", + `Amazon RVS verification failed: ${detail}`, + { originalError: detail, ...details }, + ); + } +} + export class PlayStoreServiceAccountNotFoundError extends ReceiptVerificationError { constructor() { super( diff --git a/packages/kit/convex/purchases/extract-product-id.test.ts b/packages/kit/convex/purchases/extract-product-id.test.ts index ad7aca33..3202cb5e 100644 --- a/packages/kit/convex/purchases/extract-product-id.test.ts +++ b/packages/kit/convex/purchases/extract-product-id.test.ts @@ -38,6 +38,35 @@ describe("extractProductIdFromRemoteResponse (horizon)", () => { }); }); +describe("extractProductIdFromRemoteResponse (amazon)", () => { + test("returns the Amazon RVS productId from the persisted payload", () => { + const remote = JSON.stringify({ + productId: "dev.hyo.martie.premium", + productType: "SUBSCRIPTION", + receiptId: "receipt-123", + }); + + expect(extractProductIdFromRemoteResponse("amazon", remote)).toBe( + "dev.hyo.martie.premium", + ); + }); + + test("returns null when the Amazon productId is missing or wrong type", () => { + expect( + extractProductIdFromRemoteResponse( + "amazon", + JSON.stringify({ receiptId: "receipt-123" }), + ), + ).toBeNull(); + expect( + extractProductIdFromRemoteResponse( + "amazon", + JSON.stringify({ productId: 42 }), + ), + ).toBeNull(); + }); +}); + describe("extractProductIdFromRemoteResponse", () => { it("returns null when remoteResponse is missing", () => { expect(extractProductIdFromRemoteResponse("apple", undefined)).toBeNull(); diff --git a/packages/kit/convex/purchases/shared.ts b/packages/kit/convex/purchases/shared.ts index 9289f47e..cd1d67b2 100644 --- a/packages/kit/convex/purchases/shared.ts +++ b/packages/kit/convex/purchases/shared.ts @@ -445,7 +445,7 @@ export function isValidState(state: HarmonizedPurchaseState): boolean { * so no node-only import is introduced. */ export function extractOrderIdFromRemoteResponse( - store: "apple" | "google" | "horizon", + store: "apple" | "google" | "horizon" | "amazon", remoteResponse?: string | null, ): string | null { if (store !== "google" || !remoteResponse) { @@ -515,7 +515,7 @@ export function extractOrderIdFromRemoteResponse( * importing from a query module. */ export function extractProductIdFromRemoteResponse( - store: "apple" | "google" | "horizon", + store: "apple" | "google" | "horizon" | "amazon", remoteResponse?: string | null, ): string | null { if (!remoteResponse) { @@ -575,6 +575,14 @@ export function extractProductIdFromRemoteResponse( ) { return (parsed as { sku: string }).sku; } + + if ( + store === "amazon" && + "productId" in parsed && + typeof (parsed as { productId?: unknown }).productId === "string" + ) { + return (parsed as { productId: string }).productId; + } } } catch { return null; diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 625711f0..c198ba60 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -23,6 +23,7 @@ export const purchaseStoreValidator = v.union( v.literal("apple"), v.literal("google"), v.literal("horizon"), + v.literal("amazon"), ); export const purchaseRequestDataValidator = v.union( @@ -46,6 +47,16 @@ export const purchaseRequestDataValidator = v.union( userId: v.string(), sku: v.string(), }), + // Amazon Appstore SDK IAP: RVS verifies a server-held shared secret + // with the userId + receiptId returned by Amazon's purchase APIs. + // Sandbox receipts are generated by App Tester and use the same + // shape with `sandbox: true`. + v.object({ + store: v.literal("amazon"), + userId: v.string(), + receiptId: v.string(), + sandbox: v.optional(v.boolean()), + }), ); // The schema is the source of truth for the database structure. @@ -221,6 +232,12 @@ const schema = defineSchema({ horizonAppId: v.optional(v.union(v.string(), v.null())), horizonAppSecret: v.optional(v.union(v.string(), v.null())), + // Amazon Appstore Receipt Verification Service (RVS). Production + // calls require the developer shared secret; Cloud Sandbox accepts + // any non-empty shared secret but we keep one project-level field + // so clients don't ever ship the production secret. + amazonSharedSecret: v.optional(v.union(v.string(), v.null())), + // Stable presentation currency for dashboard analytics. Raw // purchases/subscriptions keep their original store currency; // IAPKit does not do FX conversion; reporting totals only include diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt index 4273c7e1..b1d4f16b 100644 --- a/packages/kit/public/llms-full.txt +++ b/packages/kit/public/llms-full.txt @@ -38,7 +38,8 @@ Mounted at both `/v1/*` (canonical) and `/api/v1/*` (alias). ### POST /v1/purchase/verify Verify an in-app purchase. The body is a tagged union discriminated on -`store`. Response always has shape `{ isValid: boolean, state: }`. +`store`. Response always has shape +`{ store: "apple" | "google" | "horizon" | "amazon", isValid: boolean, state: }`. Request (Apple): ```json @@ -60,6 +61,16 @@ stores it as a write-only credential and the server composes the `OC|APP_ID|APP_SECRET` access token when calling `POST https://graph.oculus.com/{APP_ID}/verify_entitlement`. +Request (Amazon Appstore): +```json +{ + "store": "amazon", + "userId": "amzn1.account...", + "receiptId": "amzn1.receipt...", + "sandbox": true +} +``` + ### Aliases - `POST /v1/verify-purchase` — identical handler, kept for the path @@ -97,14 +108,15 @@ probe traffic doesn't dominate trace quota. ## Status codes -| Code | Body | When | -| ---- | ------------------------------- | ------------------------------------------------- | -| 200 | `{ isValid, state }` | Verification ran | -| 400 | `INVALID_INPUT` | Malformed body, unknown store, oversized input | -| 401 | `MISSING_API_KEY` | No `Authorization` header | -| 403 | `INVALID_API_KEY` | Wrong scheme, malformed, or unknown key | -| 429 | `RATE_LIMITED` | Per-key bucket empty; honor `Retry-After` | -| 500 | `UNKNOWN_ERROR` | Server-side failure; include `X-Correlation-Id` | +| Code | Body | When | +| ---- | ------------------------------------- | ------------------------------------------------- | +| 200 | `{ store, isValid, state }` | Verification ran | +| 400 | `INVALID_INPUT` | Malformed body, unknown store, oversized field | +| 413 | `PAYLOAD_TOO_LARGE` | Request body exceeds the 32 KB edge cap | +| 401 | `MISSING_API_KEY` | No `Authorization` header | +| 403 | `INVALID_API_KEY` | Wrong scheme, malformed, or unknown key | +| 429 | `RATE_LIMITED` | Per-key bucket empty; honor `Retry-After` | +| 500 | `UNKNOWN_ERROR` | Server-side failure; include `X-Correlation-Id` | Error body shape: ```json @@ -153,7 +165,7 @@ eviction (`RATE_LIMIT_MAX_STORE`). Oversized fields return `400 INVALID_INPUT`; oversized request bodies return `413 PAYLOAD_TOO_LARGE`. Neither path calls the upstream store, so a -misbehaving client can't burn Apple / Google / Meta quota. +misbehaving client can't burn Apple / Google / Horizon / Amazon quota. ## Structured logs diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt index 2f2c090f..3d585046 100644 --- a/packages/kit/public/llms.txt +++ b/packages/kit/public/llms.txt @@ -1,8 +1,8 @@ # IAPKit > Receipt-validation SaaS managed by OpenIAP. Hosted at https://kit.openiap.dev. -> One Bearer-authed endpoint for all three stores (Apple / Google / Horizon); -> harmonized response shape so your backend has a single code path for +> One Bearer-authed endpoint for Apple / Google / Horizon / Amazon; +> harmonized response shape with `{ store, isValid, state }` so your backend has a single code path for > entitlement + refund detection. IAPKit is a single-package Bun + Hono server bundled with a Convex backend @@ -31,11 +31,13 @@ is an alias of `/v1/purchase/verify`. Pick `/v1/purchase/verify` for new code. - Horizon — `{ store: "horizon", userId, sku }` (≤ 256 chars each). IAPKit holds the App ID + App Secret server-side and composes the `OC|APP_ID|APP_SECRET` access token per-request. +- Amazon — `{ store: "amazon", userId, receiptId, sandbox? }` where + `userId` and `receiptId` come from Amazon Appstore RVS. ## Success response ```json -{ "isValid": true, "state": "ENTITLED" } +{ "store": "amazon", "isValid": true, "state": "ENTITLED" } ``` Harmonized `state` values (truthy `isValid`): `ENTITLED`, @@ -45,7 +47,8 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, ## Status codes - `200` — verification ran; check `isValid` -- `400 INVALID_INPUT` — malformed body / unknown store / input exceeds size cap +- `400 INVALID_INPUT` — malformed body / unknown store / oversized field +- `413 PAYLOAD_TOO_LARGE` — request body exceeds the 32 KB edge cap - `401 MISSING_API_KEY` — no `Authorization` header - `403 INVALID_API_KEY` — wrong scheme, malformed, or unrecognized key - `429 RATE_LIMITED` — per-key bucket empty; honor `Retry-After` seconds @@ -65,6 +68,7 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - [/docs/verification/apple](https://kit.openiap.dev/docs/verification/apple) — bundle ID, Issuer ID, Key ID, .p8 - [/docs/verification/google](https://kit.openiap.dev/docs/verification/google) — package name, service account JSON - [/docs/verification/horizon](https://kit.openiap.dev/docs/verification/horizon) — App ID + App Secret (write-only) +- [/docs/api](https://kit.openiap.dev/docs/api) — includes Amazon RVS userId + receiptId verification payloads - [/docs/api](https://kit.openiap.dev/docs/api) — request shapes, responses, errors, headers - [/docs/operations](https://kit.openiap.dev/docs/operations) — rate limits, logs, `/health`, graceful shutdown - [openiap.dev/docs/webhooks](https://openiap.dev/docs/webhooks) — operator setup steps for the lifecycle webhook URL (Apple ASN v2 + Google RTDN) and SDK code for consuming the SSE stream diff --git a/packages/kit/server/api/v1/replay-guard.test.ts b/packages/kit/server/api/v1/replay-guard.test.ts index f7f5ce69..38e1fd10 100644 --- a/packages/kit/server/api/v1/replay-guard.test.ts +++ b/packages/kit/server/api/v1/replay-guard.test.ts @@ -56,6 +56,30 @@ describe("hashPayload", () => { }); expect(left).not.toBe(right); }); + + test("distinguishes Amazon receipt tuples and sandbox mode", () => { + const tupleLeft = hashPayload({ + store: "amazon", + userId: "ab", + receiptId: "c", + sandbox: true, + }); + const tupleRight = hashPayload({ + store: "amazon", + userId: "a", + receiptId: "bc", + sandbox: true, + }); + const production = hashPayload({ + store: "amazon", + userId: "ab", + receiptId: "c", + sandbox: false, + }); + + expect(tupleLeft).not.toBe(tupleRight); + expect(tupleLeft).not.toBe(production); + }); }); describe("tryConsumeReplay", () => { diff --git a/packages/kit/server/api/v1/replay-guard.ts b/packages/kit/server/api/v1/replay-guard.ts index 3bc4ec06..b8353dbe 100644 --- a/packages/kit/server/api/v1/replay-guard.ts +++ b/packages/kit/server/api/v1/replay-guard.ts @@ -35,9 +35,12 @@ export interface ReplayBucket { // returned `isValid: false`. Subsequent // requests for the exact same payload are short-circuited with // `REPEATED_FAILURE` until the cooldown expires — re-asking - // Apple / Google / Meta about a receipt they already rejected, or - // retrying the same failed product-match guard, has no chance of - // changing the answer in seconds. + // Apple / Google / Horizon / Amazon about a receipt they already + // rejected, or retrying the same failed product-match guard, has + // no chance of changing the answer in seconds. An attacker + // replaying a captured-then-revoked receipt should hit a hard wall + // instead of being able to rotate timing under the per-request + // burst cap to keep burning upstream API quota. lastFailureMs?: number; } @@ -71,7 +74,8 @@ export function hashPayload( body: | { store: "apple"; jws: string; expectedProductId?: string } | { store: "google"; purchaseToken: string; expectedProductId?: string } - | { store: "horizon"; userId: string; sku: string }, + | { store: "horizon"; userId: string; sku: string } + | { store: "amazon"; userId: string; receiptId: string; sandbox?: boolean }, ): string { const hasher = crypto.createHash("sha256"); hasher.update(body.store); @@ -96,6 +100,13 @@ export function hashPayload( hasher.update("\0"); hasher.update(body.sku); break; + case "amazon": + hasher.update(body.userId); + hasher.update("\0"); + hasher.update(body.receiptId); + hasher.update("\0"); + hasher.update(body.sandbox === true ? "sandbox" : "production"); + break; } return hasher.digest("hex").slice(0, 16); } @@ -183,7 +194,7 @@ export function tryConsumeReplay( * Mark the (key, payload) bucket as having just observed a failed * verification. Called from the middleware's finally block when the * handler explicitly set `verifyOutcome.isValid = false` — i.e. the - * upstream store (Apple / Google / Meta) returned a definitive "this + * upstream store (Apple / Google / Horizon / Amazon) returned a definitive "this * receipt is invalid" verdict. Thrown errors from the handler (network * failures, configuration mistakes, project-not-found, etc.) do NOT * trigger the cooldown, since those aren't a verdict from the store @@ -285,11 +296,17 @@ export function replayGuardMiddleware( } // Valid-by-schema by the time this runs — the valibot validator - // upstream guarantees one of the three discriminated shapes. + // upstream guarantees one of the discriminated shapes. const body = c.req.valid("json" as never) as | { store: "apple"; jws: string; expectedProductId?: string } | { store: "google"; purchaseToken: string; expectedProductId?: string } - | { store: "horizon"; userId: string; sku: string }; + | { store: "horizon"; userId: string; sku: string } + | { + store: "amazon"; + userId: string; + receiptId: string; + sandbox?: boolean; + }; const bucketKey = `${apiKeyHash}:${hashPayload(body)}`; const result = tryConsumeReplay( diff --git a/packages/kit/server/api/v1/request-logger.test.ts b/packages/kit/server/api/v1/request-logger.test.ts index 7fda3cdb..6d9e2215 100644 --- a/packages/kit/server/api/v1/request-logger.test.ts +++ b/packages/kit/server/api/v1/request-logger.test.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; import { apiKeyMiddleware } from "./middleware"; import { requestLoggerMiddleware, + type VerifyDebugLogLine, type VerifyLogLine, type VerifyOutcome, } from "./request-logger"; @@ -19,6 +20,8 @@ const TEST_APPLE_JWS = `${"a".repeat(42)}.${"b".repeat(42)}.${"c".repeat(42)}`; const TEST_GOOGLE_TOKEN = "t".repeat(40); const TEST_HORIZON_USER_ID = "user_123"; const TEST_HORIZON_SKU = "premium.monthly"; +const TEST_AMAZON_USER_ID = "amzn1.account.ABC123"; +const TEST_AMAZON_RECEIPT_ID = "amzn1.receipt.ABC123456789"; type TestVars = { apiKey?: string; @@ -29,6 +32,8 @@ type TestVars = { function buildApp(params: { logs: VerifyLogLine[]; + debugLogs?: VerifyDebugLogLine[]; + debug?: boolean; now?: () => number; handler?: (c: { set: (k: "verifyOutcome", v: VerifyOutcome) => void; @@ -46,6 +51,8 @@ function buildApp(params: { apiKeyMiddleware, requestLoggerMiddleware({ logger: (line) => params.logs.push(line), + debugLogger: (line) => params.debugLogs?.push(line), + debug: params.debug, now: params.now ?? defaultNow, newCorrId: () => "corr-fixed", }), @@ -55,7 +62,7 @@ function buildApp(params: { return params.handler(c); } c.set("verifyOutcome", { isValid: true, state: "ENTITLED" }); - return c.json({ isValid: true, state: "ENTITLED" }); + return c.json({ store: "apple", isValid: true, state: "ENTITLED" }); }, ); return app; @@ -122,7 +129,10 @@ describe("requestLoggerMiddleware", () => { logs, handler: (c) => { c.set("verifyOutcome", { isValid: false, state: "INAUTHENTIC" }); - return c.json({ isValid: false, state: "INAUTHENTIC" }, 500); + return c.json( + { store: "google", isValid: false, state: "INAUTHENTIC" }, + 500, + ); }, }); @@ -168,6 +178,137 @@ describe("requestLoggerMiddleware", () => { expect(logs[0].store).toBe("horizon"); }); + test("logs Amazon verification store values", async () => { + const logs: VerifyLogLine[] = []; + const app = buildApp({ logs }); + + const res = await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer key-amazon", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "amazon", + userId: TEST_AMAZON_USER_ID, + receiptId: TEST_AMAZON_RECEIPT_ID, + sandbox: true, + }), + }); + + expect(res.status).toBe(200); + expect(logs).toHaveLength(1); + expect(logs[0].store).toBe("amazon"); + }); + + test("emits redacted non-production debug logs when enabled", async () => { + const logs: VerifyLogLine[] = []; + const debugLogs: VerifyDebugLogLine[] = []; + const app = buildApp({ logs, debugLogs, debug: true }); + + const res = await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer key-amazon", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "amazon", + userId: TEST_AMAZON_USER_ID, + receiptId: TEST_AMAZON_RECEIPT_ID, + sandbox: true, + }), + }); + + expect(res.status).toBe(200); + expect(logs).toHaveLength(1); + expect(debugLogs).toHaveLength(1); + + const line = debugLogs[0]; + expect(line.kind).toBe("verify_request_debug"); + expect(line.corrId).toBe("corr-fixed"); + expect(line.statusCode).toBe(200); + expect(line.store).toBe("amazon"); + expect(line.sandbox).toBe(true); + expect(line.apiKeyHash).toMatch(/^[0-9a-f]{16}$/); + expect(line.identifiers?.userId).toEqual({ + length: TEST_AMAZON_USER_ID.length, + sha256Prefix: expect.stringMatching(/^[0-9a-f]{16}$/), + }); + expect(line.identifiers?.receiptId).toEqual({ + length: TEST_AMAZON_RECEIPT_ID.length, + sha256Prefix: expect.stringMatching(/^[0-9a-f]{16}$/), + }); + + const serializedLine = JSON.stringify(line); + expect(serializedLine).not.toContain("key-amazon"); + expect(serializedLine).not.toContain(TEST_AMAZON_USER_ID); + expect(serializedLine).not.toContain(TEST_AMAZON_RECEIPT_ID); + }); + + test("does not emit debug logs when disabled", async () => { + const logs: VerifyLogLine[] = []; + const debugLogs: VerifyDebugLogLine[] = []; + const app = buildApp({ logs, debugLogs, debug: false }); + + const res = await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer key-amazon", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "amazon", + userId: TEST_AMAZON_USER_ID, + receiptId: TEST_AMAZON_RECEIPT_ID, + sandbox: true, + }), + }); + + expect(res.status).toBe(200); + expect(logs).toHaveLength(1); + expect(debugLogs).toHaveLength(0); + }); + + test("keeps debug logs off in production even when explicitly requested", async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousKitDebug = process.env.KIT_DEBUG_VERIFY_LOGS; + + process.env.NODE_ENV = "production"; + process.env.KIT_DEBUG_VERIFY_LOGS = "1"; + + try { + const logs: VerifyLogLine[] = []; + const debugLogs: VerifyDebugLogLine[] = []; + const app = buildApp({ logs, debugLogs, debug: true }); + + const res = await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer key-amazon", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "amazon", + userId: TEST_AMAZON_USER_ID, + receiptId: TEST_AMAZON_RECEIPT_ID, + sandbox: true, + }), + }); + + expect(res.status).toBe(200); + expect(logs).toHaveLength(1); + expect(debugLogs).toHaveLength(0); + } finally { + process.env.NODE_ENV = previousNodeEnv; + if (previousKitDebug === undefined) { + delete process.env.KIT_DEBUG_VERIFY_LOGS; + } else { + process.env.KIT_DEBUG_VERIFY_LOGS = previousKitDebug; + } + } + }); + test("populates the X-Correlation-Id response header even on validator failure", async () => { const logs: VerifyLogLine[] = []; const app = buildApp({ logs }); @@ -205,7 +346,7 @@ describe("requestLoggerMiddleware", () => { validator(verifyPurchaseInputSchema), (c) => { c.set("verifyOutcome", { isValid: true, state: "ENTITLED" }); - return c.json({ isValid: true, state: "ENTITLED" }); + return c.json({ store: "apple", isValid: true, state: "ENTITLED" }); }, ); diff --git a/packages/kit/server/api/v1/request-logger.ts b/packages/kit/server/api/v1/request-logger.ts index cd4d13e1..6b21a39c 100644 --- a/packages/kit/server/api/v1/request-logger.ts +++ b/packages/kit/server/api/v1/request-logger.ts @@ -9,7 +9,7 @@ import { hashApiKey } from "./rate-limit"; // never log the plaintext API key — only the SHA-256 prefix the rate // limiter already uses — so log leaks don't become credential leaks. -export type VerifyStore = "apple" | "google" | "horizon"; +export type VerifyStore = "apple" | "google" | "horizon" | "amazon"; export interface VerifyOutcome { isValid: boolean; @@ -29,7 +29,40 @@ export interface VerifyLogLine { state?: string; } +export interface RedactedDebugValue { + length: number; + sha256Prefix: string; +} + +export interface VerifyDebugIdentifiers { + jws?: RedactedDebugValue; + purchaseToken?: RedactedDebugValue; + receiptId?: RedactedDebugValue; + userId?: RedactedDebugValue; + expectedProductId?: string; + sku?: string; +} + +export interface VerifyDebugLogLine { + kind: "verify_request_debug"; + corrId: string; + method: string; + path: string; + statusCode: number; + durationMs: number; + apiKeyHash?: string; + store?: VerifyStore; + isValid?: boolean; + state?: string; + sandbox?: boolean; + identifiers?: VerifyDebugIdentifiers; +} + export type VerifyLogger = (line: VerifyLogLine) => void; +export type VerifyDebugLogger = (line: VerifyDebugLogLine) => void; +type ValidatedJsonReader = ( + target: "json", +) => VerifyRequestBodyForLog | undefined; export const defaultVerifyLogger: VerifyLogger = (line) => { // One JSON line, `kind` up front so log queries can filter cheaply. @@ -37,12 +70,117 @@ export const defaultVerifyLogger: VerifyLogger = (line) => { console.log(JSON.stringify({ level: "info", ...line })); }; +export const defaultVerifyDebugLogger: VerifyDebugLogger = (line) => { + console.log(JSON.stringify({ level: "debug", ...line })); +}; + function describeErrorForLog(error: unknown): string { return error instanceof Error ? error.name : typeof error; } +function isProductionRuntime(): boolean { + return ( + process.env.NODE_ENV === "production" || + process.env.APP_ENV === "production" || + process.env.FLY_APP_NAME === "openiap-kit" + ); +} + +function shouldEnableDefaultDebugLogging(): boolean { + if (isProductionRuntime()) { + return false; + } + + if (process.env.KIT_DEBUG_VERIFY_LOGS === "1") { + return true; + } + + if (process.env.KIT_DEBUG_VERIFY_LOGS === "0") { + return false; + } + + if (process.env.NODE_ENV === "test" || process.env.VITEST === "true") { + return false; + } + + return true; +} + +function redactDebugValue(value: unknown): RedactedDebugValue | undefined { + if (typeof value !== "string" || value.length === 0) { + return undefined; + } + + return { + length: value.length, + sha256Prefix: crypto + .createHash("sha256") + .update(value) + .digest("hex") + .slice(0, 16), + }; +} + +function includePlainDebugValue(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0) { + return undefined; + } + + return value; +} + +interface VerifyRequestBodyForLog { + store?: VerifyStore; + jws?: string; + purchaseToken?: string; + receiptId?: string; + userId?: string; + expectedProductId?: string; + sku?: string; + sandbox?: boolean; +} + +function collectDebugIdentifiers( + body: VerifyRequestBodyForLog | undefined, +): VerifyDebugIdentifiers | undefined { + if (!body) { + return undefined; + } + + const identifiers: VerifyDebugIdentifiers = {}; + const jws = redactDebugValue(body.jws); + const purchaseToken = redactDebugValue(body.purchaseToken); + const receiptId = redactDebugValue(body.receiptId); + const userId = redactDebugValue(body.userId); + const expectedProductId = includePlainDebugValue(body.expectedProductId); + const sku = includePlainDebugValue(body.sku); + + if (jws) { + identifiers.jws = jws; + } + if (purchaseToken) { + identifiers.purchaseToken = purchaseToken; + } + if (receiptId) { + identifiers.receiptId = receiptId; + } + if (userId) { + identifiers.userId = userId; + } + if (expectedProductId) { + identifiers.expectedProductId = expectedProductId; + } + if (sku) { + identifiers.sku = sku; + } + + return Object.keys(identifiers).length > 0 ? identifiers : undefined; +} + export interface RequestLoggerConfig { logger?: VerifyLogger; + debugLogger?: VerifyDebugLogger; + debug?: boolean; now?: () => number; newCorrId?: () => string; } @@ -61,6 +199,10 @@ export function requestLoggerMiddleware( config: RequestLoggerConfig = {}, ): ReturnType> { const log = config.logger ?? defaultVerifyLogger; + const debugLog = config.debugLogger ?? defaultVerifyDebugLogger; + const shouldLogDebug = + !isProductionRuntime() && + (config.debug ?? shouldEnableDefaultDebugLogging()); const clock = config.now ?? (() => Date.now()); const newCorrId = config.newCorrId ?? (() => crypto.randomUUID()); @@ -84,10 +226,12 @@ export function requestLoggerMiddleware( const durationMs = clock() - start; let store: VerifyStore | undefined; + let body: VerifyRequestBodyForLog | undefined; try { - const body = c.req.valid("json" as never) as - | { store?: VerifyStore } - | undefined; + const readValidatedJson = c.req.valid.bind( + c.req, + ) as ValidatedJsonReader; + body = readValidatedJson("json"); store = body?.store; } catch { // Validator rejected or never ran — body may be malformed. @@ -101,6 +245,7 @@ export function requestLoggerMiddleware( // tests that don't mount the rate limiter). const apiKeyHash = c.var.apiKeyHash ?? (apiKey ? hashApiKey(apiKey) : undefined); + const statusCode = nextError && c.res.status < 400 ? 500 : c.res.status; // Swallow logger-side throws — a broken sink should never take // down a request whose real work already succeeded (or already @@ -112,7 +257,7 @@ export function requestLoggerMiddleware( corrId, method: c.req.method, path: c.req.path, - statusCode: nextError && c.res.status < 400 ? 500 : c.res.status, + statusCode, durationMs, apiKeyHash, store, @@ -125,6 +270,30 @@ export function requestLoggerMiddleware( describeErrorForLog(loggerError), ); } + + if (shouldLogDebug) { + try { + debugLog({ + kind: "verify_request_debug", + corrId, + method: c.req.method, + path: c.req.path, + statusCode, + durationMs, + apiKeyHash, + store, + isValid: outcome?.isValid, + state: outcome?.state, + sandbox: body?.sandbox, + identifiers: collectDebugIdentifiers(body), + }); + } catch (loggerError) { + console.error( + "request-debug-logger failed:", + describeErrorForLog(loggerError), + ); + } + } } }); } diff --git a/packages/kit/server/api/v1/route-input-schemas.test.ts b/packages/kit/server/api/v1/route-input-schemas.test.ts index a09eb88d..a4875c8d 100644 --- a/packages/kit/server/api/v1/route-input-schemas.test.ts +++ b/packages/kit/server/api/v1/route-input-schemas.test.ts @@ -12,6 +12,8 @@ function parse(input: unknown) { // a URL-safe blob ≥ 20 chars. const VALID_APPLE_JWS = `${"a".repeat(40)}.${"b".repeat(40)}.${"c".repeat(40)}`; const VALID_GOOGLE_TOKEN = "a".repeat(40); +const VALID_AMAZON_USER_ID = "amzn1.account.ABC123"; +const VALID_AMAZON_RECEIPT_ID = "amzn1.receipt.ABC123456789=:1"; describe("verifyPurchaseInputSchema", () => { test("accepts a well-formed Apple payload", () => { @@ -78,6 +80,50 @@ describe("verifyPurchaseInputSchema", () => { expect(result.success).toBe(true); }); + test("accepts a well-formed Amazon payload", () => { + const result = parse({ + store: "amazon", + userId: VALID_AMAZON_USER_ID, + receiptId: VALID_AMAZON_RECEIPT_ID, + sandbox: true, + }); + expect(result.success).toBe(true); + }); + + test("rejects empty Amazon userId / receiptId", () => { + expect( + parse({ + store: "amazon", + userId: "", + receiptId: VALID_AMAZON_RECEIPT_ID, + }).success, + ).toBe(false); + expect( + parse({ + store: "amazon", + userId: VALID_AMAZON_USER_ID, + receiptId: "", + }).success, + ).toBe(false); + }); + + test("rejects Amazon payloads past their ceilings", () => { + expect( + parse({ + store: "amazon", + userId: "a".repeat(513), + receiptId: VALID_AMAZON_RECEIPT_ID, + }).success, + ).toBe(false); + expect( + parse({ + store: "amazon", + userId: VALID_AMAZON_USER_ID, + receiptId: "a".repeat(4_097), + }).success, + ).toBe(false); + }); + test("rejects empty Horizon userId / sku", () => { expect( parse({ store: "horizon", userId: "", sku: "coin_pack_100" }).success, @@ -182,4 +228,21 @@ describe("verifyPurchaseInputSchema", () => { }).success, ).toBe(false); }); + + test("rejects Amazon userId / receiptId with invalid characters", () => { + expect( + parse({ + store: "amazon", + userId: "bad user", + receiptId: VALID_AMAZON_RECEIPT_ID, + }).success, + ).toBe(false); + expect( + parse({ + store: "amazon", + userId: VALID_AMAZON_USER_ID, + receiptId: "", + }).success, + ).toBe(false); + }); }); diff --git a/packages/kit/server/api/v1/route-input-schemas.ts b/packages/kit/server/api/v1/route-input-schemas.ts index f7a64ad6..81ac1773 100644 --- a/packages/kit/server/api/v1/route-input-schemas.ts +++ b/packages/kit/server/api/v1/route-input-schemas.ts @@ -6,24 +6,29 @@ import * as v from "valibot"; // push the Bun server into an OOM by posting a multi-megabyte string. // Apple JWS transactions are typically ~1–2 KB; nested subscription // payloads stay well under 10 KB. Google purchase tokens are opaque -// base64 blobs, historically under ~200 chars. Product identifiers and -// Meta Horizon's (userId, sku) are short strings. +// base64 blobs, historically under ~200 chars. Product identifiers, +// Meta Horizon's (userId, sku), and Amazon RVS's (userId, receiptId) +// are short bounded strings. export const APPLE_JWS_MAX_LENGTH = 16_000; export const GOOGLE_PURCHASE_TOKEN_MAX_LENGTH = 2_000; const HORIZON_USER_ID_MAX_LENGTH = 256; const HORIZON_SKU_MAX_LENGTH = 256; const EXPECTED_PRODUCT_ID_MAX_LENGTH = 256; +const AMAZON_USER_ID_MAX_LENGTH = 512; +const AMAZON_RECEIPT_ID_MAX_LENGTH = 4_096; // Lower bounds — any real token from the respective store sits well // above these. A sub-threshold input is guaranteed garbage (empty // fragment, single char, "test", etc.) and can be rejected at the -// edge so the quota counter and the upstream Apple / Google / Meta API +// edge so the quota counter and the upstream Apple / Google / Horizon / Amazon API // aren't touched. Numbers are conservative: the shortest real Apple // JWS we've observed in production is ~600 chars; the shortest Google // purchaseToken ~30; Meta userIds are 15-20 digit numeric strings. const APPLE_JWS_MIN_LENGTH = 100; const GOOGLE_PURCHASE_TOKEN_MIN_LENGTH = 20; const HORIZON_USER_ID_MIN_LENGTH = 3; +const AMAZON_USER_ID_MIN_LENGTH = 3; +const AMAZON_RECEIPT_ID_MIN_LENGTH = 10; // `sku` only enforces non-empty (via `v.nonEmpty` below); a 1-char // minimum would be redundant. Raise the floor here only if a real // shortest-known SKU justifies it. @@ -47,6 +52,8 @@ const GOOGLE_PURCHASE_TOKEN_PATTERN = /^[A-Za-z0-9._~-]+$/; const HORIZON_USER_ID_PATTERN = /^[A-Za-z0-9_-]+$/; const HORIZON_SKU_PATTERN = /^[A-Za-z0-9._-]+$/; const EXPECTED_PRODUCT_ID_PATTERN = /^[A-Za-z0-9._-]+$/; +const AMAZON_USER_ID_PATTERN = /^[A-Za-z0-9._~=-]+$/; +const AMAZON_RECEIPT_ID_PATTERN = /^[A-Za-z0-9._~:=/+-]+$/; const expectedProductIdSchema = v.optional( v.pipe( @@ -155,4 +162,56 @@ export const verifyPurchaseInputSchema = v.variant("store", [ }), v.title("Meta Horizon (Quest)"), ), + v.pipe( + v.object({ + store: v.literal("amazon"), + userId: v.pipe( + v.string(), + v.nonEmpty("userId must not be empty."), + v.minLength( + AMAZON_USER_ID_MIN_LENGTH, + `userId must be at least ${AMAZON_USER_ID_MIN_LENGTH} characters.`, + ), + v.maxLength( + AMAZON_USER_ID_MAX_LENGTH, + `userId must be at most ${AMAZON_USER_ID_MAX_LENGTH} characters.`, + ), + v.regex( + AMAZON_USER_ID_PATTERN, + "userId must contain only URL-safe Amazon RVS characters.", + ), + v.description( + "Amazon user id returned by PurchaseResponse.getUserData().getUserId().", + ), + ), + receiptId: v.pipe( + v.string(), + v.nonEmpty("receiptId must not be empty."), + v.minLength( + AMAZON_RECEIPT_ID_MIN_LENGTH, + `receiptId must be at least ${AMAZON_RECEIPT_ID_MIN_LENGTH} characters.`, + ), + v.maxLength( + AMAZON_RECEIPT_ID_MAX_LENGTH, + `receiptId must be at most ${AMAZON_RECEIPT_ID_MAX_LENGTH} characters.`, + ), + v.regex( + AMAZON_RECEIPT_ID_PATTERN, + "receiptId must contain only URL-safe Amazon RVS characters.", + ), + v.description( + "Amazon receipt id returned by PurchaseResponse.getReceipt().getReceiptId() or PurchaseUpdatesResponse.getReceipts().", + ), + ), + sandbox: v.optional( + v.pipe( + v.boolean(), + v.description( + "Use Amazon RVS Cloud Sandbox for App Tester receipts.", + ), + ), + ), + }), + v.title("Amazon Appstore"), + ), ]); diff --git a/packages/kit/server/api/v1/route-response-schemas.test.ts b/packages/kit/server/api/v1/route-response-schemas.test.ts new file mode 100644 index 00000000..4cf100c9 --- /dev/null +++ b/packages/kit/server/api/v1/route-response-schemas.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import * as v from "valibot"; + +import { verifyPurchaseSuccessResponseSchema } from "./route-response-schemas"; + +function parse(input: unknown) { + return v.safeParse(verifyPurchaseSuccessResponseSchema, input); +} + +describe("verifyPurchaseSuccessResponseSchema", () => { + test("requires the verified store in successful responses", () => { + const result = parse({ + store: "amazon", + isValid: true, + state: "ENTITLED", + }); + + expect(result.success).toBe(true); + }); + + test("rejects legacy responses without store", () => { + const result = parse({ + isValid: true, + state: "ENTITLED", + }); + + expect(result.success).toBe(false); + }); + + test("rejects unknown stores", () => { + const result = parse({ + store: "play-store", + isValid: true, + state: "ENTITLED", + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/kit/server/api/v1/route-response-schemas.ts b/packages/kit/server/api/v1/route-response-schemas.ts index 7ebc872a..407801e8 100644 --- a/packages/kit/server/api/v1/route-response-schemas.ts +++ b/packages/kit/server/api/v1/route-response-schemas.ts @@ -47,7 +47,7 @@ const unifiedPurchaseStates = [ }, { name: "INAUTHENTIC", - description: "Purchase not found in App Store / Google Play.", + description: "Purchase not found in the upstream store.", }, ] as const; @@ -57,7 +57,15 @@ const unifiedPurchaseStateSchema = v.union( ), ); +const verifyStoreSchema = v.union([ + v.literal("apple"), + v.literal("google"), + v.literal("horizon"), + v.literal("amazon"), +]); + const baseReceiptResponseSchema = v.object({ + store: verifyStoreSchema, isValid: v.boolean(), state: unifiedPurchaseStateSchema, productId: v.optional( diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index 3f5382a8..ca8f93c5 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -60,6 +60,7 @@ describe("apiRoutes", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ + store: "google", isValid: true, state: "ENTITLED", productId: "premium.monthly", diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 569ccecb..9092b72b 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -217,7 +217,9 @@ const verifyPurchaseRouteDescription = describeRoute({ ' • Google — `{ store: "google", purchaseToken, expectedProductId? }`\n' + ' • Horizon — `{ store: "horizon", userId, sku }` (Meta Quest;' + " IAPKit holds the App ID + App Secret and composes" + - " `OC|APP_ID|APP_SECRET` server-side)\n\n" + + " `OC|APP_ID|APP_SECRET` server-side)\n" + + ' • Amazon — `{ store: "amazon", userId, receiptId, sandbox? }`' + + " (Amazon Appstore SDK RVS; IAPKit holds the shared secret)\n\n" + "`expectedProductId` is optional for Apple / Google. When present, " + "IAPKit compares it against the product id verified by the upstream " + 'store and returns `isValid: false`, `state: "INAUTHENTIC"` on ' + @@ -236,7 +238,8 @@ const verifyPurchaseRouteDescription = describeRoute({ "headers.\n\n" + "Input size caps: request body ≤ 32 KB, `jws` ≤ 16 KB, " + "`purchaseToken` ≤ 2 KB, `expectedProductId` ≤ 256 chars, " + - "`userId` ≤ 256 chars, `sku` ≤ 256 chars. " + + "Meta `userId` ≤ 256 chars, `sku` ≤ 256 chars. " + + "Amazon `userId` ≤ 512 chars and `receiptId` ≤ 4 KB. " + "Oversized fields return `400 INVALID_INPUT`; oversized request " + "bodies return `413 PAYLOAD_TOO_LARGE`. Neither hits the upstream store.", security: [{ apiKey: [] }], @@ -334,7 +337,13 @@ const verifyPurchaseRouteDescription = describeRoute({ type VerifyPurchaseJson = | { store: "apple"; jws: string; expectedProductId?: string } | { store: "google"; purchaseToken: string; expectedProductId?: string } - | { store: "horizon"; userId: string; sku: string }; + | { store: "horizon"; userId: string; sku: string } + | { + store: "amazon"; + userId: string; + receiptId: string; + sandbox?: boolean; + }; // Tell Hono's Context what `c.req.valid("json")` returns for this // route so we don't need a `"json" as never` cast + `as VerifyPurchaseJson`. @@ -356,6 +365,14 @@ const verifyPurchaseHandler = async ( const setOutcome = (outcome: V1AppVariables["verifyOutcome"]) => { c.set("verifyOutcome", outcome); }; + const sendReceiptResponse = ( + store: VerifyPurchaseJson["store"], + receipt: { isValid: boolean; state: string; productId?: string }, + ) => { + const outcome = { isValid: receipt.isValid, state: receipt.state }; + setOutcome(outcome); + return c.json({ store, ...receipt }); + }; try { switch (json.store) { @@ -370,8 +387,7 @@ const verifyPurchaseHandler = async ( }, ); - setOutcome({ isValid: apple.isValid, state: apple.state }); - return c.json(apple); + return sendReceiptResponse("apple", apple); } case "google": { const google = await client.action( @@ -384,8 +400,7 @@ const verifyPurchaseHandler = async ( }, ); - setOutcome({ isValid: google.isValid, state: google.state }); - return c.json(google); + return sendReceiptResponse("google", google); } case "horizon": { // Meta Horizon (Quest): the client doesn't hold a @@ -403,8 +418,21 @@ const verifyPurchaseHandler = async ( }, ); - setOutcome({ isValid: horizon.isValid, state: horizon.state }); - return c.json(horizon); + return sendReceiptResponse("horizon", horizon); + } + case "amazon": { + const amazon = await client.action( + api.purchases.amazon.verifyAmazonReceiptInternalV1, + { + apiKey, + userId: json.userId, + receiptId: json.receiptId, + sandbox: json.sandbox, + requestIp, + }, + ); + + return sendReceiptResponse("amazon", amazon); } } } catch (error) { diff --git a/packages/kit/src/content/faq.md b/packages/kit/src/content/faq.md index 0bbafec7..d2d384f0 100644 --- a/packages/kit/src/content/faq.md +++ b/packages/kit/src/content/faq.md @@ -6,19 +6,19 @@ Run validation immediately after your app receives a purchase receipt and again ## Why should I perform receipt validation? -Receipts are the only authoritative source for whether a customer actually paid. Validating every transaction with a trusted server protects revenue by detecting refunded, duplicated, or jailbroken transactions before your app unlocks local access or your backend serves paid resources. It also gives you consistent purchase metadata for analytics, entitlement systems, and customer support because each validation returns normalized data across Apple and Google. +Receipts are the only authoritative source for whether a customer actually paid. Validating every transaction with a trusted server protects revenue by detecting refunded, duplicated, or jailbroken transactions before your app unlocks local access or your backend serves paid resources. It also gives you consistent purchase metadata for analytics, entitlement systems, and customer support because each validation returns normalized data across Apple, Google, Meta Horizon, and Amazon. ## What is receipt validation? -Receipt validation is the process of sending a store-issued purchase token (Apple receipt, Google purchase token, Play Billing signature, etc.) to a trusted server so it can verify the signature with Apple or Google, confirm product identifiers, amounts, and expiration, and return a definitive truth about the purchase. IAPKit abstracts the App Store and Play billing APIs behind a single REST endpoint and webhooks so your app can treat every purchase in the same way. +Receipt validation is the process of sending a store-issued purchase token (Apple receipt, Google purchase token, Horizon entitlement data, Amazon receipt ID, etc.) to a trusted server so it can verify the signature or entitlement with the source store, confirm product identifiers, amounts, and expiration, and return a definitive truth about the purchase. IAPKit abstracts supported store APIs behind a single REST endpoint and webhooks so your app can treat every purchase in the same way. ## Doesn't App Store or Google Play perform this securely already? -Apple and Google guarantee that receipts they issue are cryptographically signed, but validation still needs trusted server infrastructure instead of the device alone. Relying on local client state leaves you exposed to replayed receipts, tampered sandbox environments, and revoked subscriptions that the device hasn’t synced yet. Managed validation through IAPKit closes that gap by calling the official StoreKit and Google Play APIs, applying fraud heuristics, and giving you auditable logs if the stores ever dispute a transaction. +Store providers guarantee receipts or entitlement responses according to their own platform rules, but validation still needs trusted server infrastructure instead of the device alone. Relying on local client state leaves you exposed to replayed receipts, tampered sandbox environments, and revoked subscriptions that the device hasn’t synced yet. Managed validation through IAPKit closes that gap by calling the official store APIs, applying fraud heuristics, and giving you auditable logs if a store ever disputes a transaction. ## Will this prevent tools like Lucky Patcher? -Lucky Patcher-style tools only work when the purchase flow is trusted on-device. Because IAPKit never trusts local client state, every transaction is verified directly with Apple and Google before your app unlocks access or your backend grants an entitlement. A patched app can fake UI states, but it cannot forge the signed receipts that the stores return, so the validation step fails and the fraudulent purchase is rejected. Combine this with periodic revalidation or webhook-driven revocation to catch any attempts that slip through while the client is offline. +Lucky Patcher-style tools only work when the purchase flow is trusted on-device. Because IAPKit never trusts local client state, every transaction is verified directly with the source store before your app unlocks access or your backend grants an entitlement. A patched app can fake UI states, but it cannot forge store-issued receipts or entitlement responses, so the validation step fails and the fraudulent purchase is rejected. Combine this with periodic revalidation or webhook-driven revocation to catch any attempts that slip through while the client is offline. ## Do you track consumption state of consumable IAPs? diff --git a/packages/kit/src/content/terms-of-service.md b/packages/kit/src/content/terms-of-service.md index 5b2a4566..84e78673 100644 --- a/packages/kit/src/content/terms-of-service.md +++ b/packages/kit/src/content/terms-of-service.md @@ -142,7 +142,7 @@ We disclaim all warranties, express or implied, including: - Fitness for a particular purpose - Non-infringement -We do not guarantee accuracy of validation results beyond what is provided by Apple or Google. +We do not guarantee accuracy of validation results beyond what is provided by the source store. --- diff --git a/packages/kit/src/pages/auth/organization/project/PurchasesTable.tsx b/packages/kit/src/pages/auth/organization/project/PurchasesTable.tsx index 81ea44f0..0a279539 100644 --- a/packages/kit/src/pages/auth/organization/project/PurchasesTable.tsx +++ b/packages/kit/src/pages/auth/organization/project/PurchasesTable.tsx @@ -2,7 +2,15 @@ import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { Badge } from "@/components/Badge"; import { cn } from "@/lib/utils"; -import { Apple, CheckCircle2, Loader2, Play, XCircle } from "lucide-react"; +import { + Apple, + CheckCircle2, + Headset, + Loader2, + Play, + ShoppingBag, + XCircle, +} from "lucide-react"; import { formatReceiptDate, getPurchaseStateDisplay } from "./receipt-utils"; const storeIndicatorConfig = { @@ -14,6 +22,14 @@ const storeIndicatorConfig = { icon: Play, label: "Google Play", }, + horizon: { + icon: Headset, + label: "Meta Horizon", + }, + amazon: { + icon: ShoppingBag, + label: "Amazon Appstore", + }, } as const; type StoreIndicatorKey = keyof typeof storeIndicatorConfig; diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx index b359a088..ff7c522d 100644 --- a/packages/kit/src/pages/auth/organization/project/analytics.tsx +++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx @@ -30,7 +30,10 @@ import { api } from "@/convex"; import { PageLoading } from "@/components/LoadingSpinner"; import { cn, formatMicros, normalizeCurrencyCode } from "@/lib/utils"; -type DashboardProject = Omit, "apiKey" | "horizonAppSecret">; +type DashboardProject = Omit< + Doc<"projects">, + "apiKey" | "horizonAppSecret" | "amazonSharedSecret" +>; type ProjectContext = { project: DashboardProject }; type Platform = "IOS" | "Android"; diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index d2e2f9ce..50ef6a57 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -23,7 +23,10 @@ import { Tooltip } from "@/components/Tooltip"; import { Badge, PlatformBadge } from "../../../../components/Badge"; import { usdPriceToMicros } from "./productPrice"; -type DashboardProject = Omit, "apiKey" | "horizonAppSecret">; +type DashboardProject = Omit< + Doc<"projects">, + "apiKey" | "horizonAppSecret" | "amazonSharedSecret" +>; type ProjectContext = { project: DashboardProject }; type SyncJob = Doc<"productSyncJobs">; diff --git a/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx b/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx index c66a4b92..dc128450 100644 --- a/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx +++ b/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx @@ -126,6 +126,12 @@ export default function PurchaseDetail() { if (store === "google") { return "Google Play"; } + if (store === "horizon") { + return "Meta Horizon"; + } + if (store === "amazon") { + return "Amazon Appstore"; + } return store ?? FALLBACK_VALUE; }; diff --git a/packages/kit/src/pages/auth/organization/project/purchases.tsx b/packages/kit/src/pages/auth/organization/project/purchases.tsx index 57f2e6c2..274cc6ee 100644 --- a/packages/kit/src/pages/auth/organization/project/purchases.tsx +++ b/packages/kit/src/pages/auth/organization/project/purchases.tsx @@ -46,6 +46,7 @@ type PurchaseStats = { }; type CardKey = "total" | "apple" | "google" | "valid" | "invalid"; +type StoreFilter = "apple" | "google" | "horizon" | "amazon"; const STATS_LABELS: Record = { total: "Total Purchases", @@ -87,7 +88,12 @@ export default function ProjectPurchases() { const requestIpQuery = searchParams.get("ip") ?? ""; const storeParam = searchParams.get("store"); const storeFilter = - storeParam === "apple" || storeParam === "google" ? storeParam : undefined; + storeParam === "apple" || + storeParam === "google" || + storeParam === "horizon" || + storeParam === "amazon" + ? storeParam + : undefined; const sortFieldParam = searchParams.get("sortField") ?? "_creationTime"; const sortDirectionParam = searchParams.get("sortDirection") ?? "desc"; const sortField: "_creationTime" | "updatedAt" | "verificationDurationMs" = @@ -237,7 +243,7 @@ export default function ProjectPurchases() { setExclusiveFilters({}); }; - const applyStoreFilter = (store: "apple" | "google") => { + const applyStoreFilter = (store: StoreFilter) => { setExclusiveFilters({ store }); }; @@ -418,6 +424,14 @@ export default function ProjectPurchases() { value: "google", label: "Google Play", }, + { + value: "horizon", + label: "Meta Horizon", + }, + { + value: "amazon", + label: "Amazon Appstore", + }, ]} /> diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index d00111ba..76c82071 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -50,6 +50,10 @@ interface ProjectData { // whether one is configured, so the UI can show "Configured / // Replace" instead of a prefilled password field. hasHorizonAppSecret?: boolean; + // Amazon RVS shared secret is also write-only. The dashboard only + // receives this boolean so the browser never sees the production + // secret after setup. + hasAmazonSharedSecret?: boolean; } interface OutletContext { @@ -115,9 +119,13 @@ export default function ProjectSettings() { const [horizonAppSecret, setHorizonAppSecret] = useState(""); const [isReplacingHorizonAppSecret, setIsReplacingHorizonAppSecret] = useState(false); + const [amazonSharedSecret, setAmazonSharedSecret] = useState(""); + const [isReplacingAmazonSharedSecret, setIsReplacingAmazonSharedSecret] = + useState(false); const [savingMetadata, setSavingMetadata] = useState(false); const [savingReportingCurrency, setSavingReportingCurrency] = useState(false); const [savingHorizon, setSavingHorizon] = useState(false); + const [savingAmazon, setSavingAmazon] = useState(false); // Convex mutations for file upload const generateUploadUrl = useMutation(api.files.mutation.generateUploadUrl); @@ -168,6 +176,9 @@ export default function ProjectSettings() { const originalHorizonEnabled = Boolean(project?.horizonEnabled); const originalHorizonAppId = project?.horizonAppId ?? ""; const hasHorizonAppSecretConfigured = Boolean(project?.hasHorizonAppSecret); + const hasAmazonSharedSecretConfigured = Boolean( + project?.hasAmazonSharedSecret, + ); useEffect(() => { if (!project) { @@ -187,6 +198,8 @@ export default function ProjectSettings() { // user-typed input silently being re-submitted. setHorizonAppSecret(""); setIsReplacingHorizonAppSecret(false); + setAmazonSharedSecret(""); + setIsReplacingAmazonSharedSecret(false); }, [ project, originalAndroidPackageName, @@ -199,6 +212,7 @@ export default function ProjectSettings() { originalHorizonEnabled, originalHorizonAppId, hasHorizonAppSecretConfigured, + hasAmazonSharedSecretConfigured, ]); const trimmedAndroidPackageName = androidPackageName.trim(); @@ -210,6 +224,7 @@ export default function ProjectSettings() { const trimmedReportingCurrency = reportingCurrency.trim().toUpperCase(); const trimmedHorizonAppId = horizonAppId.trim(); const trimmedHorizonAppSecret = horizonAppSecret.trim(); + const trimmedAmazonSharedSecret = amazonSharedSecret.trim(); // Query existing files const files = useQuery( @@ -378,6 +393,17 @@ export default function ProjectSettings() { !horizonAppSecretValid || savingHorizon; + const amazonSharedSecretNeeded = + !hasAmazonSharedSecretConfigured || isReplacingAmazonSharedSecret; + const amazonSharedSecretValid = + !amazonSharedSecretNeeded || + (trimmedAmazonSharedSecret.length > 0 && + trimmedAmazonSharedSecret.length <= 2_048); + const amazonHasChanges = + amazonSharedSecretNeeded && trimmedAmazonSharedSecret.length > 0; + const disableSaveAmazon = + !amazonHasChanges || !amazonSharedSecretValid || savingAmazon; + const handleMetadataSubmit = async (event: FormEvent) => { event.preventDefault(); if (!project || disableSaveMetadata) { @@ -476,6 +502,54 @@ export default function ProjectSettings() { } }; + const handleAmazonSubmit = async () => { + if (!project || disableSaveAmazon) { + return; + } + + setSavingAmazon(true); + try { + await updateProject({ + projectId: project._id, + amazonSharedSecret: trimmedAmazonSharedSecret, + }); + + setAmazonSharedSecret(""); + setIsReplacingAmazonSharedSecret(false); + toast.success("Amazon RVS configuration saved."); + } catch (error: any) { + console.error("Amazon RVS config update error:", error); + toast.error(error.message || "Failed to save Amazon RVS configuration."); + } finally { + setSavingAmazon(false); + } + }; + + const handleAmazonClear = async () => { + if (!project || !hasAmazonSharedSecretConfigured || savingAmazon) { + return; + } + + setSavingAmazon(true); + try { + await updateProject({ + projectId: project._id, + amazonSharedSecret: null, + }); + + setAmazonSharedSecret(""); + setIsReplacingAmazonSharedSecret(false); + toast.success("Amazon RVS configuration removed."); + } catch (error: any) { + console.error("Amazon RVS config clear error:", error); + toast.error( + error.message || "Failed to remove Amazon RVS configuration.", + ); + } finally { + setSavingAmazon(false); + } + }; + const handleReportingCurrencySubmit = async ( event: FormEvent, ) => { @@ -1789,6 +1863,105 @@ export default function ProjectSettings() { )} + + {/* Amazon Appstore subsection — Fire OS builds use + Amazon's Appstore SDK and IAPKit verifies receipts + server-side through RVS with a project-level shared + secret. */} +
    +
    + + {hasAmazonSharedSecretConfigured && + !isReplacingAmazonSharedSecret ? ( +
    +
    + + + {"Shared Secret configured"} + +
    +
    + + +
    +
    + ) : ( + + setAmazonSharedSecret(e.target.value) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleAmazonSubmit(); + } + }} + className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary" + /> + )} +

    + { + "Required for production Amazon Appstore receipt verification. App Tester sandbox requests can run without exposing this secret to clients." + } +

    + {!amazonSharedSecretValid && + trimmedAmazonSharedSecret.length > 0 && ( +

    + { + "Shared Secret must be 1–2048 characters after trimming." + } +

    + )} +
    + +
    + + {"Amazon RVS docs"} + + + +
    +
    )} diff --git a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx index a3ce095b..f7309793 100644 --- a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx @@ -21,7 +21,10 @@ import { normalizeCurrencyCode, } from "@/lib/utils"; -type DashboardProject = Omit, "apiKey" | "horizonAppSecret">; +type DashboardProject = Omit< + Doc<"projects">, + "apiKey" | "horizonAppSecret" | "amazonSharedSecret" +>; type ProjectContext = { project: DashboardProject }; const STATE_FILTERS = [ diff --git a/packages/kit/src/pages/auth/organization/project/webhooks.tsx b/packages/kit/src/pages/auth/organization/project/webhooks.tsx index ab097df4..6af95314 100644 --- a/packages/kit/src/pages/auth/organization/project/webhooks.tsx +++ b/packages/kit/src/pages/auth/organization/project/webhooks.tsx @@ -15,7 +15,10 @@ import { api } from "@/convex"; import { PageLoading } from "@/components/LoadingSpinner"; type ProjectContext = { - project: Omit, "apiKey" | "horizonAppSecret">; + project: Omit< + Doc<"projects">, + "apiKey" | "horizonAppSecret" | "amazonSharedSecret" + >; }; export default function ProjectWebhooks() { @@ -70,7 +73,7 @@ export default function ProjectWebhooks() { {setup ? ( -
    +
    +
    ) : null} diff --git a/packages/kit/src/pages/blog/iapkit-joins-openiap.tsx b/packages/kit/src/pages/blog/iapkit-joins-openiap.tsx index 5140abee..cfbbdda9 100644 --- a/packages/kit/src/pages/blog/iapkit-joins-openiap.tsx +++ b/packages/kit/src/pages/blog/iapkit-joins-openiap.tsx @@ -28,7 +28,7 @@ const FAQ: Array<{ q: string; a: string }> = [ }, { q: "Which platforms does IAPKit support?", - a: "IAPKit validates receipts for Apple App Store, Google Play, and Meta Horizon. Amazon Vega OS and FireOS support is on the roadmap.", + a: "IAPKit validates receipts for Apple App Store, Google Play, Meta Horizon, and Amazon Appstore. Vega OS receipt-validation support is on the roadmap.", }, ]; @@ -125,12 +125,12 @@ export default function IapkitJoinsOpenIap() {

    When we started building IAPKit, we kept seeing the same wall stop - developers everywhere. Apple, Google, and more recently Meta Horizon — - each platform makes in-app purchase validation harder than it needs to - be, and each in its own way. Different APIs, different edge cases, - different ways to fail. Every IAP developer reinventing the same - wheel. And with new platforms like Amazon Vega OS and FireOS joining - the ecosystem, the fragmentation is only getting worse. + developers everywhere. Apple, Google, Meta Horizon, and Amazon each + make in-app purchase validation harder than it needs to be, and each + in its own way. Different APIs, different edge cases, different ways + to fail. Every IAP developer reinventing the same wheel. And with new + platforms like Vega OS joining the ecosystem, the fragmentation is + only getting worse.

    @@ -245,8 +245,8 @@ export default function IapkitJoinsOpenIap() { Billing v7+, Meta Horizon.

  • - New platform support coming — Amazon Vega OS, - FireOS. + New receipt-validation support coming — Fire OS, + Vega OS.
  • Server-side webhooks and real-time notifications — renewals, diff --git a/packages/kit/src/pages/blog/index.tsx b/packages/kit/src/pages/blog/index.tsx index 0da6c9b2..e2cf2b96 100644 --- a/packages/kit/src/pages/blog/index.tsx +++ b/packages/kit/src/pages/blog/index.tsx @@ -4,7 +4,7 @@ import { useSeoMeta, SITE_ORIGIN } from "@/hooks/useSeoMeta"; import { POSTS } from "./posts"; const BLOG_DESCRIPTION = - "Announcements, roadmap, and engineering notes from IAPKit — the OpenIAP receipt-validation service for App Store, Google Play, and Meta Horizon."; + "Announcements, roadmap, and engineering notes from IAPKit — the OpenIAP receipt-validation service for App Store, Google Play, Meta Horizon, and Amazon Appstore."; export default function BlogIndex() { const jsonLd = useMemo( diff --git a/packages/kit/src/pages/blog/posts.ts b/packages/kit/src/pages/blog/posts.ts index 825bb2fd..00a42a92 100644 --- a/packages/kit/src/pages/blog/posts.ts +++ b/packages/kit/src/pages/blog/posts.ts @@ -26,7 +26,7 @@ export const POSTS: BlogPost[] = [ excerpt: "We're moving IAPKit under OpenIAP and dropping every paywall. Receipt validation is the ground floor of IAP — it shouldn't be a tier you pay for.", description: - "IAPKit is joining OpenIAP and going completely free. No paywall, no plans, no usage limits on App Store, Google Play, and Meta Horizon receipt validation.", + "IAPKit is joining OpenIAP and going completely free. No paywall, no plans, no usage limits on App Store, Google Play, Meta Horizon, and Amazon Appstore receipt validation.", keywords: [ "IAPKit", "OpenIAP", @@ -35,6 +35,8 @@ export const POSTS: BlogPost[] = [ "App Store", "Google Play", "Meta Horizon", + "Amazon Appstore", + "Fire OS", "free IAP", ], author: BLOG_AUTHOR, diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx index da7e7e73..191b99bf 100644 --- a/packages/kit/src/pages/docs/sections/api.tsx +++ b/packages/kit/src/pages/docs/sections/api.tsx @@ -91,20 +91,31 @@ export default function ApiReferencePage() { }`} +

    Amazon Appstore variant

    + + {`{ + "store": "amazon", + "userId": "amzn1.account.ABC123", // Amazon user id (≤ 512 chars) + "receiptId": "amzn1.receipt.ABC123", // Amazon receipt id (≤ 4 KB) + "sandbox": true // App Tester / RVS sandbox +}`} + +

    The JSON body is capped at 32 KB before parsing. Every string field is then validated server-side for non-empty + per-field length bounds. Oversized fields return 400 INVALID_INPUT; oversized request bodies return 413 PAYLOAD_TOO_LARGE. Neither path - calls Apple / Google / Meta, so malformed clients don't burn your - upstream quota. + calls Apple / Google / Horizon / Amazon, so malformed clients don't + burn your upstream quota.

    Success response

    {`{ + "store": "amazon", "isValid": true, "state": "ENTITLED", "productId": "premium_monthly" @@ -112,11 +123,12 @@ export default function ApiReferencePage() {

    - Your app can unlock premium state when isValid === true.{" "} - state carries the harmonized lifecycle position across all - three stores, and productId is the product id verified by - the upstream store. For Meta Horizon, productId is the SKU - IAPKit checked. + Your app can unlock local premium state, or your backend can grant its + own entitlement, when isValid === true. state{" "} + carries the harmonized lifecycle position across all supported stores, + and productId is the product id verified by the upstream + store. For Meta Horizon, productId is the SKU IAPKit + checked.

    If your own backend keeps an entitlement ledger, do not trust a @@ -234,7 +246,7 @@ export default function ApiReferencePage() { 200 - {`{ isValid, state, productId? }`} + {`{ store, isValid, state, productId? }`} Verification completed. @@ -242,7 +254,7 @@ export default function ApiReferencePage() { 400 INVALID_INPUT - Malformed body / unknown store / input exceeds size cap. + Malformed body / unknown store / oversized field. diff --git a/packages/kit/src/pages/docs/sections/introduction.tsx b/packages/kit/src/pages/docs/sections/introduction.tsx index d34b30de..a37a97d4 100644 --- a/packages/kit/src/pages/docs/sections/introduction.tsx +++ b/packages/kit/src/pages/docs/sections/introduction.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router-dom"; -import { Apple, Smartphone, Headset } from "lucide-react"; +import { Apple, Smartphone, Headset, ShoppingBag } from "lucide-react"; import { Callout } from "../components/Callout"; import { DocsPage } from "../components/DocsPage"; @@ -9,16 +9,16 @@ export default function IntroductionPage() {

    - IAPKit, managed by OpenIAP, is a hosted - receipt-validation service for mobile and VR apps that need server-side - store verification without building their own receipt server. Your app - sends a store-specific receipt to /v1/purchase/verify, - IAPKit calls the upstream store with credentials it already holds for - your project, and returns a normalized{" "} - {`{ isValid, state, productId }`} result your app can use. + IAPKit, managed by OpenIAP, is a receipt-validation + backend for mobile and VR apps that need server-side store verification + without building their own receipt server. You send a store-specific + receipt to /v1/purchase/verify, IAPKit calls the upstream + store with credentials it already holds for your project, and returns a + normalized {`{ store, isValid, state, productId? }`} result + your app can use.

    When to reach for IAPKit

    @@ -28,20 +28,20 @@ export default function IntroductionPage() { user still has an entitlement — a refund, a chargeback, a revoked subscription, or a replayed receipt on a jailbroken device all look identical to a fresh purchase from the client's perspective. - Validating server-to-server against Apple / Google / Meta is the only - way to be certain, and that validation needs store credentials that must - stay inside IAPKit, not on a customer device. + Validating server-to-server against Apple / Google / Horizon / Amazon is + the only way to be certain, and that validation needs store credentials + that must stay inside IAPKit, not on a customer device.

    - IAPKit centralizes those credentials in one place, exposes one managed - API your app can call directly with a project API key, and harmonizes - the three stores' very different response shapes into a single - lifecycle: ENTITLED, PENDING_ACKNOWLEDGMENT,{" "} + IAPKit centralizes those credentials in one place, exposes one API your + app calls with a project API key, and harmonizes the supported stores' + very different response shapes into a single lifecycle:{" "} + ENTITLED, PENDING_ACKNOWLEDGMENT,{" "} CANCELED, and friends.

    Supported stores

    -
    +
    } title="Apple App Store" @@ -60,6 +60,12 @@ export default function IntroductionPage() { detail="Quest entitlements verified via Meta Graph API. App Secret stays server-side; clients send only (userId, sku)." slug="verification/horizon" /> + } + title="Amazon Appstore" + detail="Fire OS receipts verified through Amazon RVS using the project's shared secret. Clients send only (userId, receiptId)." + slug="api" + />

    Architecture

    @@ -75,10 +81,10 @@ export default function IntroductionPage() { ───────────────────── ────────────────── ───────────────── POST /v1/purchase/verify apiKey → project Bearer ───► verify action ───► App Store / Play / - { store, ... } Meta Graph API + { store, ... } Horizon / Amazon ◄── verified receipt - { isValid, state, - productId } ◄─── harmonized state + { store, isValid, state, + productId? } ◄─── harmonized state `} diff --git a/packages/kit/src/pages/docs/sections/projects.tsx b/packages/kit/src/pages/docs/sections/projects.tsx index 5b75c534..642c419e 100644 --- a/packages/kit/src/pages/docs/sections/projects.tsx +++ b/packages/kit/src/pages/docs/sections/projects.tsx @@ -23,7 +23,7 @@ export default function ProjectsPage() {
             {`Organization (members, usage)
       └── Project (one mobile app)
    -        ├── Store credentials (Apple, Google, Horizon)
    +        ├── Store credentials (Apple, Google, Horizon, Amazon)
             └── API keys ── used by your app
     `}
           
    diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx index 4bf996b2..49920739 100644 --- a/packages/kit/src/pages/docs/sections/quickstart.tsx +++ b/packages/kit/src/pages/docs/sections/quickstart.tsx @@ -124,7 +124,7 @@ export default function QuickstartPage() {

    From your app, send the receipt to IAPKit. Here's the raw HTTP shape for - each of the three stores: + each supported store:

    @@ -160,9 +160,22 @@ export default function QuickstartPage() { }'`} + + {`curl -X POST https://kit.openiap.dev/v1/purchase/verify \\ + -H "Authorization: Bearer openiap-kit_" \\ + -H "Content-Type: application/json" \\ + -d '{ + "store": "amazon", + "userId": "amzn1.account.ABC123", + "receiptId": "amzn1.receipt.ABC123456789", + "sandbox": true + }'`} + +

    Expected response:

    {`{ + "store": "amazon", "isValid": true, "state": "ENTITLED", "productId": "premium_monthly" diff --git a/packages/kit/src/pages/docs/sections/verification-apple.tsx b/packages/kit/src/pages/docs/sections/verification-apple.tsx index 46339351..f96ea5b3 100644 --- a/packages/kit/src/pages/docs/sections/verification-apple.tsx +++ b/packages/kit/src/pages/docs/sections/verification-apple.tsx @@ -177,6 +177,7 @@ if (revocationDate !== undefined) { language="json" > {`{ + "store": "apple", "isValid": false, "state": "CANCELED", "productId": "premium_monthly" diff --git a/packages/kit/src/pages/landing.tsx b/packages/kit/src/pages/landing.tsx index 935d3464..617e40ed 100644 --- a/packages/kit/src/pages/landing.tsx +++ b/packages/kit/src/pages/landing.tsx @@ -121,7 +121,7 @@ export default function LandingPage() { {"Purpose-built for fraud-resistant IAP validation"}

    - {"One API to confirm every App Store and Play Store purchase."} + {"One API to confirm every supported store purchase."}

    @@ -149,7 +149,7 @@ export default function LandingPage() {

    { - "Add one IAPKit call and handle both stores with the same payload. You'll be up and running in minutes." + "Add one IAPKit call and handle every supported store with the same payload. You'll be up and running in minutes." }

  • @@ -181,7 +181,7 @@ export default function LandingPage() {

    { - "Collect the receipt on-device, send it to IAPKit, and unlock the item once we confirm it with Apple or Google." + "Collect the receipt on-device, send it to IAPKit, and unlock the item once the source store confirms it." }

      @@ -189,7 +189,7 @@ export default function LandingPage() { { - "Collect the App Store / Play receipt with your existing billing library." + "Collect the App Store, Play, Horizon, or Amazon receipt with your existing billing library." } diff --git a/scripts/agent/compile-context.ts b/scripts/agent/compile-context.ts index 52666295..17aa2929 100644 --- a/scripts/agent/compile-context.ts +++ b/scripts/agent/compile-context.ts @@ -38,54 +38,75 @@ const CONFIG = { rootLlmsOutputDir: path.resolve(scriptDir, "../.."), }; -function readInstallationVersions(): { +type LlmsVersions = { apple: string; + flutter: string; google: string; godot: string; kmp: string; maui: string; mauiPackageId: string; -} { - const versionsPath = path.join(CONFIG.projectRoot, "openiap-versions.json"); - const versions = JSON.parse(fs.readFileSync(versionsPath, "utf-8")) as { - apple?: string; - google?: string; - }; - const kmpProperties = fs.readFileSync( - path.join(CONFIG.projectRoot, "libraries/kmp-iap/gradle.properties"), - "utf-8", - ); - const godotPlugin = fs.readFileSync( - path.join(CONFIG.projectRoot, "libraries/godot-iap/addons/godot-iap/plugin.cfg"), - "utf-8", - ); - const mauiProject = fs.readFileSync( - path.join(CONFIG.projectRoot, "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj"), +}; + +function readJsonFile(relativePath: string): T { + return JSON.parse( + fs.readFileSync(path.join(CONFIG.projectRoot, relativePath), "utf-8"), + ) as T; +} + +function readRegexVersion( + relativePath: string, + pattern: RegExp, + label: string, +): string { + const content = fs.readFileSync( + path.join(CONFIG.projectRoot, relativePath), "utf-8", ); - const kmpVersion = kmpProperties.match(/^libraryVersion=(.+)$/m)?.[1]?.trim(); - const godotVersion = godotPlugin.match(/^version="([^"]+)"$/m)?.[1]?.trim(); - const mauiPackageId = mauiProject.match(/([^<]+)<\/PackageId>/)?.[1]?.trim(); - const mauiVersion = mauiProject.match(/([^<]+)<\/PackageVersion>/)?.[1]?.trim(); - - if (!versions.apple || !versions.google) { - throw new Error("openiap-versions.json must include apple and google versions"); - } - if (!godotVersion || !kmpVersion || !mauiPackageId || !mauiVersion) { - throw new Error("Framework package metadata is missing godot, kmp, or maui values"); + const version = content.match(pattern)?.[1]?.trim(); + if (!version) { + throw new Error(`Unable to resolve ${label} version from ${relativePath}`); } + return version; +} + +function readInstallationVersions(): LlmsVersions { + const openiapVersions = readJsonFile<{ apple: string; google: string }>( + "openiap-versions.json", + ); return { - apple: versions.apple, - godot: godotVersion, - google: versions.google, - kmp: kmpVersion, - maui: mauiVersion, - mauiPackageId, + apple: openiapVersions.apple, + google: openiapVersions.google, + flutter: readRegexVersion( + "libraries/flutter_inapp_purchase/pubspec.yaml", + /^version:\s*([^\s]+)/m, + "flutter_inapp_purchase", + ), + godot: readRegexVersion( + "libraries/godot-iap/addons/godot-iap/plugin.cfg", + /^version="([^"]+)"$/m, + "godot-iap", + ), + kmp: readRegexVersion( + "libraries/kmp-iap/gradle.properties", + /^libraryVersion=(.+)$/m, + "kmp-iap", + ), + maui: readRegexVersion( + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + /([^<]+)<\/PackageVersion>/, + "OpenIap.Maui", + ), + mauiPackageId: readRegexVersion( + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + /([^<]+)<\/PackageId>/, + "OpenIap.Maui package id", + ), }; } -function withSingleTrailingNewline(content: string): string { +function withFinalNewline(content: string): string { return `${content.trimEnd()}\n`; } @@ -96,6 +117,7 @@ function withSingleTrailingNewline(content: string): string { async function generateLlmsTxt(): Promise<{ quick: number; full: number }> { console.log(chalk.blue("\n🤖 Generating llms.txt files...\n")); const versions = readInstallationVersions(); + const generatedAt = new Date().toISOString(); // Read all external API docs const externalFiles = await glob( @@ -109,15 +131,16 @@ async function generateLlmsTxt(): Promise<{ quick: number; full: number }> { > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: ${new Date().toISOString()} +> Generated: ${generatedAt} ## Table of Contents 1. Installation 2. Core APIs (Connection, Products, Purchase, Subscription) 3. Platform-Specific APIs (iOS, Android) -4. Types Reference -5. Error Codes & Handling -6. Implementation Patterns +4. Store Targets (Play, Horizon, Fire OS, Vega OS) +5. Types Reference +6. Error Codes & Handling +7. Implementation Patterns --- @@ -149,6 +172,9 @@ implementation("io.github.hyochan.openiap:openiap-google:${versions.google}") // For Meta Horizon OS implementation("io.github.hyochan.openiap:openiap-google-horizon:${versions.google}") + +// For Fire OS (Amazon Appstore) +implementation("io.github.hyochan.openiap:openiap-google-amazon:${versions.google}") \`\`\` ### Flutter @@ -189,6 +215,9 @@ Requires .NET 9+, 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 can select Play, Horizon, or Fire OS artifacts. + Vega OS resolves a \`kepler\` JavaScript adapter before creating the Nitro + HybridObject. - Example app: \`libraries/react-native-iap/example\`. ### expo-iap @@ -196,6 +225,9 @@ Requires .NET 9+, 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 or Fire OS Android flavors; + Vega OS follows the Onside-style runtime selector pattern with a JavaScript + adapter. - Example app: \`libraries/expo-iap/example\`. ### flutter_inapp_purchase @@ -204,6 +236,7 @@ Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. iOS and Android method channels. - Public surface: singleton \`FlutterInappPurchase.instance\`, typed \`fetchProducts\`, purchase streams, and resolver-style methods. +- Android builds can select Play, Horizon, or Fire OS flavors. ### godot-iap - Package: \`godot-iap\` for Godot 4.x. @@ -242,6 +275,51 @@ Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. --- +## Store Targets + +- Google Play: default Android artifact, \`openiap-google\`. +- Meta Horizon: Android \`horizon\` flavor, \`openiap-google-horizon\`. +- Fire OS: Android \`amazon\` flavor, + \`openiap-google-amazon\`; set \`amazon.fireOS=true\`, + \`fireOsEnabled=true\`, or + \`missingDimensionStrategy("platform", "amazon")\`. + Runtime adapters are wired for native Android, \`react-native-iap\`, + \`expo-iap\`, and \`flutter_inapp_purchase\`; Godot, KMP, and MAUI have schema + type parity but still need Android wrapper flavor switches. +- Vega OS: not an Android flavor. Target React Native for Vega / Expo only, + using Amazon's JavaScript IAP API through the runtime-selected \`kepler\` + adapter at the same runtime integration layer as Onside. In Expo or React + Native config plugin options, use \`amazon.vegaOS=true\`. It marks the Vega + runtime target and does not select an Android flavor. \`amazon.fireOS\` and + \`amazon.vegaOS\` can both be enabled when an app produces separate Fire OS + and Vega OS artifacts. + +### Fire OS + +Fire OS is an Android target for Amazon Appstore distribution. It uses the +\`amazon\` Gradle flavor and Amazon Appstore SDK. + +Fire OS maps OpenIAP calls to the Amazon Appstore SDK: + +| OpenIAP API | Amazon Appstore SDK mapping | +|-------------|--------------------------| +| \`initConnection()\` | Register \`PurchasingListener\`, request user data | +| \`fetchProducts()\` | \`PurchasingService.getProductData\` | +| \`requestPurchase()\` | \`PurchasingService.purchase\` | +| \`getAvailablePurchases()\` | \`PurchasingService.getPurchaseUpdates(reset=true)\` | +| \`finishTransaction()\` | \`PurchasingService.notifyFulfillment(..., FULFILLED)\` | + +### Vega OS Runtime + +Vega OS is not Fire OS and is not selected with \`fireOsEnabled=true\`; that +flag is only for Android Fire OS builds. Use \`amazon.vegaOS=true\` for the +Vega runtime target and \`amazon.fireOS=true\` for separate Fire OS Android +artifacts. 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. + +--- + ## Minimal Usage by Framework ### React Native / Expo @@ -352,7 +430,7 @@ await ((QueryResolver)iap).FetchProductsAsync(new ProductRequest > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: ${new Date().toISOString()} +> Generated: ${generatedAt} ## Installation @@ -374,6 +452,8 @@ npm install react-native-iap \`\`\`kotlin // Gradle implementation("io.github.hyochan.openiap:openiap-google:${versions.google}") +implementation("io.github.hyochan.openiap:openiap-google-horizon:${versions.google}") +implementation("io.github.hyochan.openiap:openiap-google-amazon:${versions.google}") \`\`\` \`\`\`bash @@ -391,9 +471,9 @@ flutter pub add flutter_inapp_purchase implementation("io.github.hyochan:kmp-iap:${versions.kmp}") \`\`\` -\`\`\`bash -# .NET MAUI -dotnet add package ${versions.mauiPackageId} +\`\`\`xml + + \`\`\` Current NuGet package version: ${versions.maui} @@ -429,7 +509,7 @@ await endConnection(); \`\`\`typescript const products = await fetchProducts({ products: [ - { id: 'com.app.premium', type: 'inapp' }, + { id: 'com.app.premium', type: 'in-app' }, { id: 'com.app.monthly', type: 'subs' }, ], }); @@ -444,7 +524,7 @@ await requestPurchase({ apple: { sku: 'com.app.premium' }, google: { skus: ['com.app.premium'] }, }, - type: 'inapp', // 'inapp' | 'subs' + type: 'in-app', // 'in-app' | 'subs' }); \`\`\` @@ -452,7 +532,7 @@ await requestPurchase({ \`\`\`typescript // CRITICAL: Must call after verification // Android: purchases auto-refund after 3 days if not acknowledged -await finishTransaction(purchase, isConsumable); +await finishTransaction({ purchase, isConsumable }); \`\`\` ### Get Available Purchases @@ -477,7 +557,7 @@ const purchaseUpdateSubscription = purchaseUpdatedListener(async (purchase) => { // 1. Verify purchase on server // 2. Grant entitlement // 3. Finish transaction - await finishTransaction(purchase); + await finishTransaction({ purchase, isConsumable: false }); }); const purchaseErrorSubscription = purchaseErrorListener((error) => { @@ -501,7 +581,7 @@ interface Product { price: string; // Formatted price string priceAmount: number; // Price as number currency: string; // ISO 4217 currency code - type: 'inapp' | 'subs'; + type: 'in-app' | 'subs'; } \`\`\` @@ -586,11 +666,11 @@ interface PurchaseError { fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync( path.join(outputDir, "llms.txt"), - withSingleTrailingNewline(quickContent), + withFinalNewline(quickContent), ); fs.writeFileSync( path.join(outputDir, "llms-full.txt"), - withSingleTrailingNewline(fullContent), + withFinalNewline(fullContent), ); } @@ -741,7 +821,7 @@ openiap/ // ========================================================================= const outputPath = path.join(CONFIG.outputDir, CONFIG.outputFile); - fs.writeFileSync(outputPath, withSingleTrailingNewline(output)); + fs.writeFileSync(outputPath, withFinalNewline(output)); // ========================================================================= // Generate LLMs.txt Files @@ -777,7 +857,9 @@ openiap/ ), ); console.log( - chalk.green(` ✓ Output: ${path.join(CONFIG.rootLlmsOutputDir, "llms.txt")}`), + chalk.green( + ` ✓ Output: ${path.join(CONFIG.rootLlmsOutputDir, "llms.txt")}`, + ), ); console.log( chalk.green(