diff --git a/.claude/commands/audit-code.md b/.claude/commands/audit-code.md index 1a25b4360..de470fc02 100644 --- a/.claude/commands/audit-code.md +++ b/.claude/commands/audit-code.md @@ -37,8 +37,8 @@ Use WebSearch to get the latest platform API information: **Google Play Billing Library:** - Search: "Google Play Billing Library release notes site:developer.android.com" -- Check for new features in latest version (currently 8.x) -- Key areas: one-time products, subscription offers, billing programs +- Check for new features in the latest stable version (currently 9.1.0) +- Key areas: one-time products, subscription offers, billing programs, Billing Choice, in-app messages **Apple StoreKit 2:** @@ -79,9 +79,14 @@ Compare current implementation against latest platform APIs: | -------------------------------------- | ------- | -------------------------------------- | | One-time products with multiple offers | 8.0 | Is it in schema? | | Product-level status codes | 8.0 | Returned in fetchProducts? | +| BillingResult sub-response codes | 8.0 | Preserved through every wrapper? | | Suspended subscriptions (isSuspended) | 8.1 | Purchase type has it? | | includeSuspended parameter | 8.1 | getAvailablePurchases supports it? | | Billing Programs API | 8.2 | isBillingProgramAvailable implemented? | +| External Payments | 8.3 | Developer billing fields wired? | +| Nullable developer billing link URI | 9.0 | Null and empty URI handled safely? | +| Opt-in price increase in-app messages | 9.0 | showInAppMessagesAndroid implemented? | +| Billing Choice | 9.1 | Info, dialog, and choice type wired? | **StoreKit 2 (check packages/gql/src/api-ios.graphql):** @@ -98,7 +103,7 @@ Compare current implementation against latest platform APIs: | Feature | Check | | ------------------------------------- | ------------------------------- | -| horizon-billing-compatibility version | Is it latest? (currently 1.1.1) | +| horizon-billing-compatibility version | Is it latest? (currently 2.0.0) | | API parity with Play flavor | Same APIs available in both? | | Shared code compatibility | Uses only Billing 7.0 APIs? | | getAvailableItems (Horizon-only) | Implemented? | @@ -108,7 +113,7 @@ Compare current implementation against latest platform APIs: | Check | Expected | | --------------------------- | --------------------------- | -| Play flavor Billing version | 8.x | +| Play flavor Billing version | 9.1.0 | | Horizon SDK compatible with | Billing 7.0 API | | Shared code uses | Only 7.0-compatible APIs | | react-native-iap requires | v14+, RN 0.79+, Kotlin 2.0+ | @@ -140,15 +145,17 @@ packages/gql (GraphQL): **Latest API Coverage:** -- [ ] Google Play Billing 8.x features implemented +- [ ] Google Play Billing 9.1.0 features implemented end-to-end - [ ] StoreKit 2 iOS 18+ features implemented - [ ] Meta Horizon Billing SDK up to date - [ ] External API docs updated with new features +- [ ] New API comments lead with OpenIAP spec/package versions, then list the + upstream platform SDK requirement **Version Compatibility:** - [ ] horizon-billing-compatibility matches latest -- [ ] Shared code avoids Billing 8.x-only APIs +- [ ] Shared code avoids Billing 8.x/9.x-only APIs - [ ] react-native-iap/expo-iap compatible versions documented ### 6. Fix Issues @@ -312,7 +319,7 @@ For each new feature implemented: - [ ] **Type docs** - New types documented with all fields explained - [ ] **Example apps** - Working examples in iOS and Android example apps - [ ] **Code examples** - Inline code examples in documentation -- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 8.0+") +- [ ] **Platform notes** - Version requirements (e.g., "iOS 18+", "Billing 9.1+") - [ ] **Cross-references** - Links between related functions/types - [ ] **Search** - New items added to search index @@ -386,7 +393,7 @@ Then ask Claude to: Ask Claude Code: > "Run /audit-code with latest API check" -> "Audit the codebase including latest Google Play Billing 8.x features" +> "Audit the codebase including latest Google Play Billing 9.1.0 features" > "Check implementation against latest StoreKit 2 iOS 18.4 APIs" ## Output diff --git a/.claude/commands/e2e-tests.md b/.claude/commands/e2e-tests.md index 0f3931501..11b2344db 100644 --- a/.claude/commands/e2e-tests.md +++ b/.claude/commands/e2e-tests.md @@ -280,7 +280,7 @@ cd android # 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 +adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1 ``` Normal iOS physical-device build and launch smoke: @@ -290,9 +290,9 @@ 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 \ + -workspace ios/ExpoIAPExample.xcworkspace \ -configuration Debug \ - -scheme expoiapexample \ + -scheme ExpoIAPExample \ -destination "id=$IOS_UDID" \ DEVELOPMENT_TEAM="$TEAM_ID" \ -derivedDataPath build/DerivedData \ @@ -301,10 +301,10 @@ xcodebuild \ # Build-only regression can stop here. xcrun devicectl device install app \ --device "$IOS_UDID" \ - build/DerivedData/Build/Products/Debug-iphoneos/expoiapexample.app + build/DerivedData/Build/Products/Debug-iphoneos/ExpoIAPExample.app xcrun devicectl device process launch \ --device "$IOS_UDID" \ - dev.hyo.openiap.expo.example + dev.hyo.martie ``` FireOS/Amazon Android path: @@ -317,7 +317,7 @@ cd android # 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 +adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1 ``` Horizon Android build-only path: @@ -335,9 +335,9 @@ Onside iOS build-only path: cd libraries/expo-iap/example EXPO_IAP_ONSIDE=1 bunx expo prebuild --platform ios --clean xcodebuild \ - -workspace ios/expoiapexample.xcworkspace \ + -workspace ios/ExpoIAPExample.xcworkspace \ -configuration Debug \ - -scheme expoiapexample \ + -scheme ExpoIAPExample \ -destination "generic/platform=iOS" \ CODE_SIGNING_ALLOWED=NO ``` @@ -363,6 +363,7 @@ Library: ```bash cd libraries/react-native-iap +yarn specs yarn lint:tsc yarn test:library --runInBand ``` @@ -402,6 +403,11 @@ Normal iOS physical-device build and launch smoke: 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}" +# Refresh Pods after Nitro type generation so newly generated headers are +# linked into Pods/Headers before the physical-device build. +cd ios +bundle exec pod install +cd .. xcodebuild \ -workspace ios/example.xcworkspace \ -configuration Debug \ @@ -577,7 +583,10 @@ cd libraries/maui-iap/android ../../../packages/google/gradlew :openiap:assembleRelease -PopenIapAndroidStore=play cd .. dotnet build-server shutdown || true -rm -rf src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj +rm -rf \ + src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj \ + src/OpenIap.Maui/bin src/OpenIap.Maui/obj \ + example/OpenIap.Maui.Example/bin example/OpenIap.Maui.Example/obj dotnet build src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj \ -p:TargetFrameworks=net9.0-android \ -p:OpenIapAndroidStore=play \ @@ -590,14 +599,14 @@ dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj \ dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ -f net9.0-android \ -p:OpenIapAndroidStore=play \ + -p:EmbedAssembliesIntoApk=true \ --nologo # Build-only regression can stop here. : "${ANDROID_SERIAL:?Set ANDROID_SERIAL to the target Android device serial}" -ANDROID_SERIAL="$ANDROID_SERIAL" dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ - -t:Run \ - -f net9.0-android \ - -p:OpenIapAndroidStore=play \ - --nologo +adb -s "$ANDROID_SERIAL" uninstall dev.hyo.martie || true +adb -s "$ANDROID_SERIAL" install --no-incremental -r \ + example/OpenIap.Maui.Example/bin/Debug/net9.0-android/dev.hyo.martie-Signed.apk +adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1 ``` FireOS/Amazon Android build and launch smoke: @@ -607,7 +616,10 @@ cd libraries/maui-iap/android ../../../packages/google/gradlew :openiap:assembleRelease -PopenIapAndroidStore=amazon cd .. dotnet build-server shutdown || true -rm -rf src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj +rm -rf \ + src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj \ + src/OpenIap.Maui/bin src/OpenIap.Maui/obj \ + example/OpenIap.Maui.Example/bin example/OpenIap.Maui.Example/obj dotnet build src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj \ -p:TargetFrameworks=net9.0-android \ -p:OpenIapAndroidStore=amazon \ @@ -620,14 +632,14 @@ dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj \ dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ -f net9.0-android \ -p:OpenIapAndroidStore=amazon \ + -p:EmbedAssembliesIntoApk=true \ --nologo # Build-only regression can stop here. : "${FIREOS_SERIAL:?Set FIREOS_SERIAL to the target FireOS device serial}" -ANDROID_SERIAL="$FIREOS_SERIAL" dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ - -t:Run \ - -f net9.0-android \ - -p:OpenIapAndroidStore=amazon \ - --nologo +adb -s "$FIREOS_SERIAL" uninstall dev.hyo.martie || true +adb -s "$FIREOS_SERIAL" install --no-incremental -r \ + example/OpenIap.Maui.Example/bin/stores/amazon/Debug/net9.0-android/dev.hyo.martie-Signed.apk +adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1 ``` Horizon Android build-only path: @@ -637,7 +649,10 @@ cd libraries/maui-iap/android ../../../packages/google/gradlew :openiap:assembleRelease -PopenIapAndroidStore=horizon cd .. dotnet build-server shutdown || true -rm -rf src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj +rm -rf \ + src/OpenIap.Maui.Bindings.Android/bin src/OpenIap.Maui.Bindings.Android/obj \ + src/OpenIap.Maui/bin src/OpenIap.Maui/obj \ + example/OpenIap.Maui.Example/bin example/OpenIap.Maui.Example/obj dotnet build src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj \ -p:TargetFrameworks=net9.0-android \ -p:OpenIapAndroidStore=horizon \ @@ -658,7 +673,17 @@ 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" +dotnet build \ + -f net9.0-ios \ + -p:RuntimeIdentifier=ios-arm64 \ + -p:ValidateXcodeVersion=false \ + --nologo +xcrun devicectl device install app \ + --device "$IOS_UDID" \ + bin/Debug/net9.0-ios/ios-arm64/OpenIap.Maui.Example.app +xcrun devicectl device process launch \ + --device "$IOS_UDID" \ + dev.hyo.martie ``` ## Godot Checks diff --git a/.claude/guides/05-google-package.md b/.claude/guides/05-google-package.md index df9ee97a1..ad6e87753 100644 --- a/.claude/guides/05-google-package.md +++ b/.claude/guides/05-google-package.md @@ -33,10 +33,11 @@ packages/google/ ## Build Variants -| Variant | Purpose | Billing Library | -| --------- | ------------------ | ---------------- | -| `play` | Google Play Store | Play Billing 8.x | -| `horizon` | Meta Horizon Store | Horizon SDK | +| Variant | Purpose | Billing Library | +| --------- | ------------------ | ------------------------- | +| `play` | Google Play Store | Play Billing 9.1.0 | +| `horizon` | Meta Horizon Store | Horizon SDK 2.0.0 | +| `amazon` | Amazon Appstore | Amazon Appstore SDK 3.0.8 | ## Type Generation @@ -52,10 +53,12 @@ Types.kt is auto-generated. **Never edit directly!** # Compile library ./gradlew :openiap:compilePlayDebugKotlin ./gradlew :openiap:compileHorizonDebugKotlin +./gradlew :openiap:compileAmazonDebugKotlin # Run tests ./gradlew :openiap:testPlayDebugUnitTest ./gradlew :openiap:testHorizonDebugUnitTest +./gradlew :openiap:testAmazonDebugUnitTest # Build Example app ./gradlew :Example:assemblePlayDebug diff --git a/.codex/skills/generate-doc/SKILL.md b/.codex/skills/generate-doc/SKILL.md index d545efaa1..62b01b85e 100644 --- a/.codex/skills/generate-doc/SKILL.md +++ b/.codex/skills/generate-doc/SKILL.md @@ -92,6 +92,28 @@ Follow the existing card pattern: - Do not edit `packages/docs/src/generated/version-metadata.json` manually; it is produced by `./scripts/sync-versions.sh`. +## Multi-package Release Trains + +The consolidated release page remains the release-note SSOT, but a release that +ships several packages must still be readable package by package. This is the +project decision recorded from issue #206. + +- When the user gives a starting commit, inspect that commit inclusively through + the latest target branch, then include the current PR diff. Do not derive the + release contents only from the PR title or its latest commits. +- Group notable changes under the affected platform package or framework + library (Google, Apple, IAPKit, React Native, Expo, Flutter, Godot, KMP, and + MAUI). Omit groups with no user-facing change. +- Keep each group concise. State the behavior users gain or the regression that + was fixed; do not list commit mechanics, version-bump-only commits, generated + files, or repeated cross-framework boilerplate. +- Put truly shared schema or release-process changes in one short shared group, + then describe framework-specific wiring or caveats in the relevant framework + group. +- Link the issues and PRs that explain user-visible fixes. Keep package-local + changelogs as pointers to this canonical entry unless a registry requires an + inline changelog. + ## Validation For docs-only release-note edits, run: @@ -110,4 +132,3 @@ Before committing to `main`, pull first: ```bash git pull --ff-only origin main ``` - diff --git a/AGENTS.md b/AGENTS.md index f0596e64a..8d7ad5390 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,16 +6,16 @@ This document provides an overview for AI agents working across the OpenIAP mono ## 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) | +| 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) | +| 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 @@ -79,7 +79,9 @@ openiap/ - `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` +- `openiap-versions.json` - Tracks only `spec`, `google`, and `apple`. Google + and Apple are CI-managed; the spec may be bumped directly in a feature PR + when explicitly requested, together with `packages/gql/package.json`. Framework library package versions (React Native, Expo, Flutter, Godot, KMP, MAUI) live in their own package metadata / release workflows. Do not add diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 6a7217a74..e6ce7761e 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-07-04T10:52:01.931Z +> Last updated: 2026-07-10T22:14:59.408Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -882,6 +882,14 @@ swift build # Verifies ObjC bridge compiles ## SDK Parity Checklist (CRITICAL — prevents "declared but not implemented") +### API version annotations + +For newly exposed platform features, public schema and API documentation must +name the OpenIAP versions first and the upstream SDK requirement second. Use the +format `OpenIAP Spec / openiap-google (requires Play Billing ++)`. Upstream-only labels such as `Billing 9.1.0+` do not tell OpenIAP +consumers which library release contains the API. + When the GraphQL schema in [`packages/gql`](../../packages/gql) adds or changes an API, the regenerated `types.*` files **declare** the handler but do not **implement** it. Every wrapper library must wire the new API end-to-end or users will see silent nulls, phantom interfaces (GitHub issue #104), or `UnsupportedOperationException` at runtime. The mechanical guardrail for this checklist is: @@ -934,13 +942,13 @@ GraphQL schema ─► generated types ─► public API ─► native bridge ─ For every new/changed handler in the generated types, verify **all five** of these per target library before considering the change shippable: -| Library | 1. Type declared | 2. Public API exposed | 3. Platform bridge | 4. Wired into handlers bundle | 5. Test coverage | -| -------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **react-native-iap** | `src/types.ts` (generated) | `src/index.ts` export (Nitro or composed TS) | `ios/HybridRnIap.swift` (iOS), `android/.../HybridRnIap.kt` (Android) | Not required (flat exports) | Mock stub in all 4 `mockIap` objects in `__tests__/` (per memory) | -| **expo-iap** | `src/types.ts` (generated) | `src/modules/ios.ts` / `android.ts` export, re-exported from `src/index.ts` | `ios/ExpoIapModule.swift` `AsyncFunction`, `android/.../ExpoIapModule.kt` | Not required (flat exports) | `src/modules/__tests__/*.test.ts` | -| **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift` and `macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | -| **kmp-iap** | `library/src/commonMain/.../openiap/Types.kt` (generated interface) | exposed via `KmpInAppPurchase` / `kmpIapInstance` | `library/src/iosMain/.../InAppPurchaseIOS.kt` — must call `openIapModule.WithCompletion { ... }`, **never** `throw UnsupportedOperationException` | Not required (interface dispatch) | `library/src/commonTest/` if testable cross-platform | -| **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | Manual testing — no automated test suite yet | +| Library | 1. Type declared | 2. Public API exposed | 3. Platform bridge | 4. Wired into handlers bundle | 5. Test coverage | +| -------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **react-native-iap** | `src/types.ts` (generated) | `src/index.ts` export (Nitro or composed TS) | `ios/HybridRnIap.swift` (iOS), `android/.../HybridRnIap.kt` (Android) | Not required (flat exports) | Mock stub in all 4 `mockIap` objects in `__tests__/` (per memory) | +| **expo-iap** | `src/types.ts` (generated) | `src/modules/ios.ts` / `android.ts` export, re-exported from `src/index.ts` | `ios/ExpoIapModule.swift` `AsyncFunction`, `android/.../ExpoIapModule.kt` | Not required (flat exports) | `src/modules/__tests__/*.test.ts` | +| **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift` and `macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | +| **kmp-iap** | `library/src/commonMain/.../openiap/Types.kt` (generated interface) | exposed via `KmpInAppPurchase` / `kmpIapInstance` | `library/src/iosMain/.../InAppPurchaseIOS.kt` — must call `openIapModule.WithCompletion { ... }`, **never** `throw UnsupportedOperationException` | Not required (interface dispatch) | `library/src/commonTest/` if testable cross-platform | +| **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | Manual testing — no automated test suite yet | | **maui-iap** | `src/OpenIap.Maui/Types.cs` (generated) | `OpenIap.QueryResolver` / `MutationResolver` interfaces in `Types.cs`; `IOpenIap` adds the listener-stream contract; static facade is `OpenIap.Maui.OpenIapClient` (`OpenIap.Maui.Iap` remains as a legacy shim); IAPKit helpers mirror TypeScript via `OpenIapClient.KitApi(...)`, `OpenIapClient.ConnectWebhookStream(...)`, `OpenIapClient.ParseWebhookEventData(...)`, and `OpenIapClient.WebhookEventTypes` | Android: `OpenIapMauiModule.kt` in `libraries/maui-iap/android/openiap/` (JSON-shaped Java facade over `packages/google`), bound by `OpenIap.Maui.Bindings.Android.csproj`, consumed by `Platforms/Android/OpenIapAndroid.cs`. Google Billing / Play Services / Gson / AndroidX / Kotlin dependencies must stay NuGet `PackageReference`s, not fat-bundled AARs. iOS / macCatalyst: existing `OpenIapModule+ObjC.swift` bridge in `packages/apple`, bound by hand-written `OpenIap.Maui.Bindings.iOS/ApiDefinition.cs`, consumed by `Platforms/iOS/OpenIapIOS.cs` (+ subclass `OpenIapMacCatalyst`). | Not required (interface dispatch) | Example app `libraries/maui-iap/example/OpenIap.Maui.Example` builds for net9.0-android / net9.0-ios / net9.0-maccatalyst; package CI builds net9/net10 shared, Android, iOS, and macCatalyst TFMs (manual device testing for purchase flow); no xUnit tests yet | ### Platform suffix rule (who needs what) @@ -1060,21 +1068,26 @@ The Google package supports **two build flavors**: | Flavor | Billing Library | Version | | ------- | ----------------------------- | -------------------------- | -| Play | Google Play Billing | 8.3.0 | -| Horizon | horizon-billing-compatibility | 1.1.1 (GPB 7.0 compatible) | +| Play | Google Play Billing | 9.1.0 | +| Horizon | horizon-billing-compatibility | 2.0.0 (GPB 7.0 compatible) | -**CRITICAL**: Horizon SDK implements **Billing 7.0 API**, not 8.x. When writing shared code in `src/main/`: +**CRITICAL**: Horizon SDK implements **Billing 7.0 API**, not 8.x/9.x. When writing shared code in `src/main/`: -**Safe APIs (exist in both 7.0 and 8.x):** +**Safe APIs (exist in both 7.0 and 9.x):** - `queryProductDetailsAsync()`, `launchBillingFlow()` - `acknowledgePurchase()`, `consumeAsync()`, `queryPurchasesAsync()` -**DO NOT use in shared code (8.x only):** +**DO NOT use in shared code (8.x/9.x only):** - `enableAutoServiceReconnection()` - Product-level status codes - One-time products with multiple offers +- Suspended-subscription queries and product-level replacement parameters +- Billing Programs APIs, including External Payments and Billing Choice + +Keep those APIs in `src/play/`. Billing Choice information, dialogs, choice +screen types, and developer-provided billing fields require Play Billing 9.1.0. ### Horizon-Specific APIs @@ -1535,6 +1548,25 @@ Framework implementation listings must be derived from Release notes are located at `packages/docs/src/pages/docs/updates/releases.tsx`. +### Package-specific grouping for shared releases + +The docs release page is the canonical release-note SSOT, including when many +packages ship together. To satisfy the package-specific changelog requirement +from issue #206 without duplicating release history across package-local files: + +- Audit the full requested commit range inclusively and include the current PR + diff before drafting the note. +- Group user-visible changes by affected platform package or framework library: + Google, Apple, IAPKit, React Native, Expo, Flutter, Godot, KMP, and MAUI. +- Omit packages with no user-visible change and keep each remaining group to the + smallest set of useful upgrade notes. +- Do not replace package-specific behavior with a generic "framework parity" + bullet when wrappers have different setup, runtime, or compatibility details. +- Exclude version-only commits, generated-file churn, and CI mechanics unless + they change how users install, build, or validate the release. +- Keep package-local changelogs as pointers to this page and GitHub Releases, + except where a package registry requires generated inline history. + ### Adding New Release Notes 1. Add new entry at the **top** of the `allNotes` array @@ -1853,13 +1885,16 @@ linking it. This prevents stale Package Releases tables such as documenting ### openiap-versions.json -**CRITICAL: NEVER manually edit `openiap-versions.json`** +**CRITICAL: NEVER manually edit the `google` or `apple` fields in +`openiap-versions.json`.** -This file is automatically managed by CI/CD workflows during releases: +Version ownership is split: - Apple releases update `apple` version - Google releases update `google` version -- GQL releases update `spec` version +- The shared spec can be bumped directly in a feature PR when the maintainer + explicitly requests the target version. Update both `spec` and + `packages/gql/package.json`, then run `./scripts/sync-versions.sh`. - Deploy script (`npm run deploy`) uses the current `spec` version by default, and updates `spec` only when an explicit version is passed @@ -1869,15 +1904,20 @@ The manifest is only for the shared spec and native platform packages: `kmp-iap`, `maui-iap`) must stay in each library's own package metadata and release workflow, not as extra keys in `openiap-versions.json`. -Manual edits will cause version conflicts and deployment issues. Always use the GitHub Actions workflows or deploy script to update versions. +Manual Google or Apple edits will cause version conflicts and deployment +issues. Use their GitHub Actions workflows. A direct spec edit is the explicit +exception above. **Why this matters:** If a feature PR sets `apple: "2.1.1"` manually, and then CI auto-bumps on release, CI sees "current is 2.1.1" and bumps to 2.1.2 — skipping 2.1.1 entirely. The published tag becomes 2.1.2 with no 2.1.1 ever existing. -**Rule:** Feature PRs must NEVER touch version fields in `openiap-versions.json`. Version bumps happen only via: +**Rule:** Feature PRs must never touch `google` or `apple`. Version bumps happen +via: 1. Release workflows (Apple Release, Google Release) -2. Deploy script (`npm run deploy`, optionally `npm run deploy `) -3. CI auto-bump after merge +2. A maintainer-requested direct `spec` bump paired with + `packages/gql/package.json` +3. Deploy script (`npm run deploy`, optionally `npm run deploy `) +4. CI auto-bump after merge where configured --- @@ -2468,7 +2508,7 @@ cd ../flutter_inapp_purchase && flutter analyze **Horizon flavor (do NOT attempt)** - The Horizon flavor's `addSubscriptionBillingIssueListener` is a documented no-op. Verified by - `SubscriptionBillingIssueHorizonNoOpTest` (Robolectric, runs on CI). There is no sandbox path on Horizon because the Billing Compatibility SDK 1.1.1 targets Play Billing 7.0 which does not expose `Purchase.isSuspended`. + `SubscriptionBillingIssueHorizonNoOpTest` (Robolectric, runs on CI). There is no sandbox path on Horizon because the Billing Compatibility SDK 2.0.0 targets Play Billing 7.0 which does not expose `Purchase.isSuspended`. --- @@ -2885,7 +2925,7 @@ await endConnection(); # Google Play Billing Library API Reference -> Reference documentation for Google Play Billing Library 8.x +> Reference documentation for Google Play Billing Library 9.x > Adapt all patterns to match OpenIAP internal conventions. ## Overview @@ -2901,8 +2941,15 @@ Google Play Billing Library enables in-app purchases and subscriptions on Androi | 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API | | 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` | | 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options | +| 9.0 | 2026-05-19 | Removes older deprecated APIs, reclassifies blocked Play Store activity errors, adds richer sub-response handling, target SDK 35 | +| 9.1 | 2026-06-18 | Billing Choice APIs: `getBillingChoiceInfoAsync()`, `showBillingProgramInformationDialog()`, choice-screen details | + +**Current Version**: 9.1.0 (as of July 2026) -**Current Version**: 8.3.0 (as of April 2026) +> **OpenIAP audit note**: `packages/google` is pinned to Play Billing 9.1.0. +> Billing Choice APIs are implemented only in the Play flavor; Horizon and +> Amazon variants keep unsupported/default behavior for APIs that do not exist +> in their store SDKs. ## Core Classes @@ -2913,7 +2960,11 @@ The main interface for communicating with Google Play Billing. ```kotlin val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) - .enablePendingPurchases() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) // New in 8.0: Auto-reconnect on service disconnect .enableAutoServiceReconnection() .build() @@ -2930,7 +2981,8 @@ BillingClient.newBuilder(context) When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors. -> **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed. +> **OpenIAP Note**: Auto-reconnection is enabled internally when the Play +> Billing version exposes the API. No OpenIAP app-level configuration is needed. ### Connection Management @@ -3216,11 +3268,11 @@ billingClient.queryPurchasesAsync(params) { billingResult, purchases -> ```kotlin val result = billingClient.launchBillingFlow(activity, params) -when (result.subResponseCode) { - BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> { +when (result.onPurchasesUpdatedSubResponseCode) { + BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -> { // User's payment method has insufficient funds } - BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> { + BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE -> { // User doesn't meet offer eligibility requirements } } @@ -3232,6 +3284,11 @@ when (result.subResponseCode) { | `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility | | `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies | +PBL 9 makes sub-response-code handling part of the migration checklist. It also +changes blocked Play Store app cases from generic `ERROR` to +`BILLING_UNAVAILABLE`, with a debug message explaining that Play Store is +blocked. + ## Subscription Product Replacement (8.1+) Product-level replacement parameters for subscription upgrades/downgrades: @@ -3270,10 +3327,15 @@ Billing Library 8.3 (December 2025) added support for the External Payments prog // During BillingClient setup val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) - .enablePendingPurchases() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) .enableAutoServiceReconnection() - .enableDeveloperBillingOption( - DeveloperBillingOptionParams.newBuilder() + .enableBillingProgram( + EnableBillingProgramParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.EXTERNAL_PAYMENTS) .setDeveloperProvidedBillingListener(developerBillingListener) .build() ) @@ -3283,10 +3345,12 @@ val billingClient = BillingClient.newBuilder(context) ### DeveloperProvidedBillingListener ```kotlin -val developerBillingListener = DeveloperProvidedBillingListener { - userInitiatedBillingDetails -> - // User chose the developer-provided billing flow. - // Launch your external payment UI here. +val developerBillingListener = DeveloperProvidedBillingListener { details -> + // All nullable fields depend on the selected program and flow. + val token: String? = details.externalTransactionToken + val linkUri: String? = details.linkUri + val originalTransactionId: String? = details.originalExternalTransactionId + val products: List = details.products } ``` @@ -3295,7 +3359,15 @@ val developerBillingListener = DeveloperProvidedBillingListener { ```kotlin val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productDetailsParams)) - .setBillingOption(BillingOption.EXTERNAL_PAYMENTS) // 8.3+ + .enableDeveloperBillingOption( + DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.EXTERNAL_PAYMENTS) + .setLinkUri(Uri.parse("https://example.com/checkout")) + .setLaunchMode( + DeveloperBillingOptionParams.LaunchMode.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP + ) + .build() + ) .build() billingClient.launchBillingFlow(activity, params) @@ -3305,22 +3377,163 @@ billingClient.launchBillingFlow(activity, params) | Type | Purpose | |------|---------| -| `DeveloperBillingOptionParams` | Configures developer-billing support on `BillingClient` | +| `DeveloperBillingOptionParams` | Configures developer billing on `BillingFlowParams` | | `DeveloperProvidedBillingListener` | Callback when user picks developer-provided billing | -| `DeveloperProvidedBillingDetails` | Billing details to report back for reconciliation | -| `BillingOption.EXTERNAL_PAYMENTS` | Purchase-flow flag requesting external payments | +| `DeveloperProvidedBillingDetails` | Nullable token/link/original-ID fields plus selected products | +| `BillingClient.BillingProgram.EXTERNAL_PAYMENTS` | External Payments program constant | + +> **OpenIAP Note**: Exposed through `enableBillingProgramAndroid`, +> `developerBillingOption`, and the developer-provided billing listener. +> Enrolment with Google Play's External Payments program is required; +> availability is currently restricted to Japan. Horizon and Amazon do not +> implement this Google Play program. + +## Billing Choice (9.1+) + +Billing Library 9.1 adds APIs for markets and programs where either Google Play +or the app renders a billing choice screen. + +### Integration Scenarios + +| Scenario | Choice renderer | Developer payment | BillingClient setup | Required flow | +|----------|-----------------|-------------------|---------------------|---------------| +| 1A | Google | In app | `EnableBillingProgramParams` with `DeveloperProvidedBillingListener` | Pass a minimal `DeveloperBillingOptionParams`; Play returns the token through the listener | +| 1B | Developer | In app | `EnableBillingProgramParams` without the listener | Fetch choice info, create an `IN_APP` token, show the information dialog, then render the choice UI | +| 2A | Google | External link | `EnableBillingProgramParams` with `DeveloperProvidedBillingListener` | Create an `EXTERNAL_LINK` token and pass it with the URI through `DeveloperBillingOptionParams` | +| 2B | Developer | External link | `EnableBillingProgramParams` without the listener | Fetch choice info, create an `EXTERNAL_LINK` token, render the choice UI, then pass the token to `launchExternalLink` | + +The setup must match `choiceScreenType` from Play Console. Registering the +listener in a developer-rendered integration is not equivalent to omitting it. + +| API / Type | Purpose | +|------------|---------| +| `BillingClient.getBillingChoiceInfoAsync()` | Fetches billing choices available to the current user | +| `BillingChoiceInfo` | Contains choice-screen data, including image URLs and loyalty details | +| `GetBillingChoiceInfoParams` | Configures the billing-choice info request | +| `BillingClient.showBillingProgramInformationDialog()` | Shows an information dialog for a billing program | +| `BillingProgramInformationDialogParams` | Configures the information dialog | +| `LaunchExternalLinkParams.setExternalTransactionToken()` | Supplies the pre-generated token for a developer-rendered external-link flow | +| `BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails` | Returns choice-screen type and external-link availability | +| `DeveloperBillingOptionParams` | Selects in-app or external-link developer billing during purchase | +| `BillingProgramReportingDetailsParams.DeveloperBillingType` | Distinguishes `IN_APP` and `EXTERNAL_LINK` reporting | + +### Developer Billing Purchase Options + +Only `billingProgram` is required for an in-app Billing Choice flow: + +```kotlin +val inAppChoice = DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .build() +``` + +For a Google-rendered external-link flow, also set the URI, launch mode, and the +pre-generated `EXTERNAL_LINK` transaction token: + +```kotlin +val externalLinkChoice = DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setLinkUri(Uri.parse("https://example.com/checkout")) + .setLaunchMode(DeveloperBillingOptionParams.LaunchMode.CALLER_WILL_LAUNCH_LINK) + .setExternalTransactionToken(preGeneratedToken) + .build() +``` + +### Developer-Rendered Choice Information + +```kotlin +val params = GetBillingChoiceInfoParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setPlayBillingChoiceImageLayout( + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_FOUR_BY_ONE + ) + .setUserLocale(Locale.forLanguageTag("en-US")) + .build() + +billingClient.getBillingChoiceInfoAsync(params) { result, info -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + val imageUrl = info.playBillingChoiceImageUrl + val loyaltyText = info.playBillingLoyaltyInfo + } +} +``` -> **OpenIAP Note**: Exposed through the Android-specific `AlternativeBilling*` surface in OpenIAP. Enrolment with Google Play's External Payments program is required; availability is currently restricted to Japan. The Horizon flavor does NOT implement this. +Supported image layouts are `RECTANGULAR_FOUR_BY_ONE`, +`RECTANGULAR_THREE_BY_ONE`, and `RECTANGULAR_TWO_BY_TWO`. + +### Availability Details + +For `BILLING_CHOICE`, `BillingProgramAvailabilityDetails` can include: + +| Field | Meaning | +|-------|---------| +| `choiceScreenType` | `UNSPECIFIED`, `DEVELOPER_RENDERED`, or `GOOGLE_RENDERED` | +| `isExternalLinkAvailable` | Whether the user is eligible for an external-link option | + +### Information Dialog + +For developer-rendered in-app choice (scenario 1B), call +`showBillingProgramInformationDialog()` before showing the app's choice UI. It +is a UI-thread API and returns through its listener; it does not return a +synchronous `BillingResult`: + +```kotlin +val params = BillingProgramInformationDialogParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setExternalTransactionToken(externalTransactionToken) + .build() + +billingClient.showBillingProgramInformationDialog(activity, params) { result -> + // Continue according to result.responseCode. +} +``` + +### Developer-Billed Subscription Replacement + +Use the original external transaction ID instead of an old Play purchase token +when replacing a subscription bought through developer billing: + +```kotlin +val updateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOriginalExternalTransactionId(originalExternalTransactionId) + .build() +``` + +> **OpenIAP Note**: OpenIAP exposes these through `BILLING_CHOICE`, +> `getBillingChoiceInfoAndroid`, `showBillingProgramInformationDialogAndroid`, +> `launchExternalLinkAndroid`, `developerBillingOption`, +> `originalExternalTransactionId`, and the expanded developer-provided billing +> callback. Set `InitConnectionConfig.billingChoiceScreenTypeAndroid` to +> `GOOGLE_RENDERED` (default) or `DEVELOPER_RENDERED` so OpenIAP includes or +> omits the listener correctly. Play-only APIs return unsupported/default +> behavior on Horizon and Amazon. + +## In-App Billing Messages (4.1+) + +`showInAppMessages()` must run on the UI thread. It returns a synchronous +`BillingResult` for submission errors and reports the user interaction through +`InAppMessageResponseListener`. + +## PBL 9 Migration Guardrails + +- Replace removed APIs: `SkuDetails`, `SkuDetailsParams`, `SkuDetailsResponseListener`, `BillingClient.SkuType`, `querySkuDetailsAsync()`, no-argument `enablePendingPurchases()`, and string `queryPurchasesAsync()`. +- Use `ProductDetails`, `QueryProductDetailsParams`, `BillingClient.ProductType`, parameterized `enablePendingPurchases(PendingPurchasesParams)`, and `queryPurchasesAsync(QueryPurchasesParams, ...)`. +- Handle `DeveloperProvidedBillingDetails.getExternalTransactionToken()`, + `getLinkUri()`, and `getOriginalExternalTransactionId()` as nullable. +- Preserve every `DeveloperProvidedBillingDetails.Product` (`id`, `type`, and + nullable `offerToken`) from the callback. +- Keep Horizon shared code on the Billing 7.0-compatible API subset; put PBL 8/9 code in Play-only sources or behind reflection. ## Best Practices 1. **Always acknowledge purchases** within 3 days or they will be refunded 2. **Verify purchases server-side** using Google Play Developer API 3. **Handle pending purchases** for payment methods that require additional steps -4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+) +4. **Auto-reconnect is enabled by default** in OpenIAP when available (8.0+) 5. **Check product status codes** (8.0+) to understand why products weren't fetched 6. **Check isSuspended** (8.1+) before granting entitlements -7. **Cache product details** to avoid repeated queries +7. **Distinguish in-app and external-link Billing Choice** when configuring and reporting developer billing +8. **Cache product details** to avoid repeated queries --- @@ -3343,18 +3556,32 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two | Library | Version | Compatible With | |---------|---------|-----------------| -| horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API | -| Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A | +| horizon-billing-compatibility | **2.0.0** (latest) | Google Play Billing **7.0** API | +| Google Play Billing (upstream latest) | **9.1.0** | N/A | +| Google Play Billing (OpenIAP Play flavor) | **9.1.0** | N/A | | react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ | | expo-iap | latest | Billing 7.0+, Kotlin 2.0+ | -**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. +**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x or 9.x. When writing shared code for both Play and Horizon flavors: -- Use only APIs that exist in **both** Billing 7.0 and 8.x -- Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended` +- Use only APIs that exist in **both** Billing 7.0 and the Play-flavor Billing version +- Horizon SDK does NOT support Billing 8.x/9.x features like auto-reconnect, product status codes, `includeSuspended`, or Billing Choice - OpenIAP handles this automatically with flavor-specific implementations +### Latest Horizon Billing Release + +Meta released Horizon Billing Compatibility Library **2.0.0** on 2026-01-06. +The release notes call out a fix for querying subscription purchases with a +single billing plan and dependencies on Horizon Platform SDK Kotlin +`iap-kotlin` 0.2.0 and `core-kotlin` 0.2.0. + +OpenIAP uses `horizon-billing-compatibility` 2.0.0 and its transitive Horizon +Platform Kotlin SDK modules. The Horizon flavor is compiled and tested +separately, including subscription-purchase restore coverage. Play Billing +9.1.0 features remain Play-flavor-only because Horizon compatibility still +targets the Billing 7.0 API surface. + ### APIs Available in Both (Safe to use in shared code) - `BillingClient.Builder`, `BillingClient.newBuilder()` @@ -3376,6 +3603,12 @@ When writing shared code for both Play and Horizon flavors: - Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+) - External Payments / Developer Billing Options (8.3+) +### APIs Only in Billing 9.x (DO NOT use in shared code) + +- Billing Choice information APIs (`getBillingChoiceInfoAsync`, `BillingChoiceInfo`, `ChoiceScreenType`) +- Billing-program information dialog APIs (`showBillingProgramInformationDialog`) +- PBL 9 migration assumptions around APIs removed from the Billing 7.0 surface + ## Billing Compatibility SDK For apps already using Google Play Billing Library, the Horizon Billing Compatibility SDK provides a minimal migration path. @@ -3409,6 +3642,10 @@ import com.meta.horizon.billingclient.api.* ### Important Notes +- Horizon Billing Compatibility 2.x reads the app id from Android manifest + meta-data key `com.meta.horizon.platform.HORIZON_APP_ID`. The older + `com.meta.horizon.platform.ovr.OCULUS_APP_ID` key is deprecated; OpenIAP also + accepts it and other historical keys only for migration compatibility. - Keep SKUs on Meta Horizon Developer Center same as Google Play Console product IDs - Only call `consumeAsync()` on consumable items - `acknowledgePurchase()` is no-op - no acknowledgement requirements @@ -4024,7 +4261,7 @@ export default withIAPContext(Store); This document provides external API reference for Apple's StoreKit 2 framework. -## iOS 18+ Features +## iOS 18+ / 26+ Features | Feature | iOS Version | Description | |---------|-------------|-------------| @@ -4043,6 +4280,11 @@ This document provides external API reference for Apple's StoreKit 2 framework. | JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format | | `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option | | `SubscriptionStatus` by Transaction ID | WWDC 2025 | `status(for: transactionID:)` | +| Monthly subscriptions with a 12-month commitment | iOS 26.4 / 26.5 SDK | Monthly billing option for annual auto-renewable subscriptions | +| Group purchases and volume purchasing | WWDC 2026 | Multi-seat auto-renewable subscriptions through StoreKit 2 and Apple Business / School Manager | +| Retention Messaging | WWDC 2026 | Cancellation-flow messaging and offers, including real-time server decisioning | +| Retention offer type | WWDC 2026 | Signed transaction / renewal info can report offer type `5` for retention offers | +| Offer codes for all IAP types | 2026 | Offer codes expand beyond auto-renewable subscriptions; IAP promo-code creation ends March 26, 2026 | ### WWDC 2025 Updates @@ -4051,6 +4293,14 @@ This document provides external API reference for Apple's StoreKit 2 framework. - **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option. - Both new purchase options are back-deployed to iOS 15. +### WWDC 2026 Updates + +- **Monthly subscriptions with a 12-month commitment**: iOS 26.5 SDK adds a monthly billing plan for one-year auto-renewable subscriptions. Customers can subscribe on iOS, iPadOS, macOS, tvOS, and visionOS 26.4+. +- **Group purchases and volume purchasing**: Auto-renewable subscriptions using StoreKit 2 can be sold to groups and organizations. In-app group purchases pass a requested seat count into the StoreKit purchase flow; Apple Business Manager and Apple School Manager handle volume purchasing. +- **Volume pricing**: App Store Connect can configure up to five seat-count price bands for larger subscription purchases. +- **Retention Messaging**: App Store Connect can show cancellation-flow retention messages and offers. Real-time Retention Messaging adds a server-to-server decision point and supports a switch-plan view for monthly subscriptions with a 12-month commitment. +- **Offer-code expansion**: Offer codes now support consumables, non-consumables, non-renewing subscriptions, and broader auto-renewable subscription scenarios. Starting March 26, 2026, App Store Connect no longer creates new promo codes for In-App Purchases. + ## appAccountToken A UUID that associates a purchase with a user account in your system. This property allows you to correlate App Store transactions with users in your backend. @@ -4332,6 +4582,43 @@ if let advancedInfo = product.advancedCommerceInfo { } ``` +## Monthly Subscriptions With 12-Month Commitment (iOS 26.4+) + +This billing plan lets customers pay monthly while committing to an annual +auto-renewable subscription. Apps need to compile with the iOS 26.5 SDK to +merchandise the plan, and customers can purchase on Apple platforms running +26.4 or later. + +```swift +let result = try await product.purchase(options: [ + .billingPlanType(.monthly) +]) +``` + +> **OpenIAP Note**: The schema represents this with +> `SubscriptionBillingPlanTypeIOS` and `RequestSubscriptionIOSProps.billingPlanType`. + +## Group Purchases and Volume Purchasing (WWDC 2026) + +StoreKit 2 auto-renewable subscriptions can be sold to multiple seats for +groups or organizations. Volume purchasing is handled by Apple Business Manager +and Apple School Manager. For in-app group purchases, the app starts a StoreKit +purchase with the requested seat count, then Apple can manage invitation links +and seat assignment unless the app integrates custom group management. + +> **OpenIAP gap**: No public OpenIAP request field exists yet for group-purchase +> seat count or StoreKit group-management identifiers. + +## Retention Messaging (WWDC 2026) + +Retention Messaging lets App Store Connect present messages and optional offers +when a subscriber is about to cancel. Real-time Retention Messaging can call a +server endpoint so the developer can choose the message, offer, or switch-plan +view at cancellation time. + +Signed transaction and renewal information can include a retention offer as +offer type `5`. + ## StoreKit Message API (iOS 18+) Listen for App Store–generated messages (billing issues, win-back offers, price increases, generic). diff --git a/knowledge/external/google-billing-api.md b/knowledge/external/google-billing-api.md index e5a9b386e..4dde1564c 100644 --- a/knowledge/external/google-billing-api.md +++ b/knowledge/external/google-billing-api.md @@ -1,6 +1,6 @@ # Google Play Billing Library API Reference -> Reference documentation for Google Play Billing Library 8.x +> Reference documentation for Google Play Billing Library 9.x > Adapt all patterns to match OpenIAP internal conventions. ## Overview @@ -16,8 +16,15 @@ Google Play Billing Library enables in-app purchases and subscriptions on Androi | 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API | | 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` | | 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options | +| 9.0 | 2026-05-19 | Removes older deprecated APIs, reclassifies blocked Play Store activity errors, adds richer sub-response handling, target SDK 35 | +| 9.1 | 2026-06-18 | Billing Choice APIs: `getBillingChoiceInfoAsync()`, `showBillingProgramInformationDialog()`, choice-screen details | -**Current Version**: 8.3.0 (as of April 2026) +**Current Version**: 9.1.0 (as of July 2026) + +> **OpenIAP audit note**: `packages/google` is pinned to Play Billing 9.1.0. +> Billing Choice APIs are implemented only in the Play flavor; Horizon and +> Amazon variants keep unsupported/default behavior for APIs that do not exist +> in their store SDKs. ## Core Classes @@ -28,7 +35,11 @@ The main interface for communicating with Google Play Billing. ```kotlin val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) - .enablePendingPurchases() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) // New in 8.0: Auto-reconnect on service disconnect .enableAutoServiceReconnection() .build() @@ -45,7 +56,8 @@ BillingClient.newBuilder(context) When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors. -> **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed. +> **OpenIAP Note**: Auto-reconnection is enabled internally when the Play +> Billing version exposes the API. No OpenIAP app-level configuration is needed. ### Connection Management @@ -331,11 +343,11 @@ billingClient.queryPurchasesAsync(params) { billingResult, purchases -> ```kotlin val result = billingClient.launchBillingFlow(activity, params) -when (result.subResponseCode) { - BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> { +when (result.onPurchasesUpdatedSubResponseCode) { + BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -> { // User's payment method has insufficient funds } - BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> { + BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE -> { // User doesn't meet offer eligibility requirements } } @@ -347,6 +359,11 @@ when (result.subResponseCode) { | `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility | | `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies | +PBL 9 makes sub-response-code handling part of the migration checklist. It also +changes blocked Play Store app cases from generic `ERROR` to +`BILLING_UNAVAILABLE`, with a debug message explaining that Play Store is +blocked. + ## Subscription Product Replacement (8.1+) Product-level replacement parameters for subscription upgrades/downgrades: @@ -385,10 +402,15 @@ Billing Library 8.3 (December 2025) added support for the External Payments prog // During BillingClient setup val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) - .enablePendingPurchases() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) .enableAutoServiceReconnection() - .enableDeveloperBillingOption( - DeveloperBillingOptionParams.newBuilder() + .enableBillingProgram( + EnableBillingProgramParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.EXTERNAL_PAYMENTS) .setDeveloperProvidedBillingListener(developerBillingListener) .build() ) @@ -398,10 +420,12 @@ val billingClient = BillingClient.newBuilder(context) ### DeveloperProvidedBillingListener ```kotlin -val developerBillingListener = DeveloperProvidedBillingListener { - userInitiatedBillingDetails -> - // User chose the developer-provided billing flow. - // Launch your external payment UI here. +val developerBillingListener = DeveloperProvidedBillingListener { details -> + // All nullable fields depend on the selected program and flow. + val token: String? = details.externalTransactionToken + val linkUri: String? = details.linkUri + val originalTransactionId: String? = details.originalExternalTransactionId + val products: List = details.products } ``` @@ -410,7 +434,15 @@ val developerBillingListener = DeveloperProvidedBillingListener { ```kotlin val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productDetailsParams)) - .setBillingOption(BillingOption.EXTERNAL_PAYMENTS) // 8.3+ + .enableDeveloperBillingOption( + DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.EXTERNAL_PAYMENTS) + .setLinkUri(Uri.parse("https://example.com/checkout")) + .setLaunchMode( + DeveloperBillingOptionParams.LaunchMode.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP + ) + .build() + ) .build() billingClient.launchBillingFlow(activity, params) @@ -420,19 +452,160 @@ billingClient.launchBillingFlow(activity, params) | Type | Purpose | |------|---------| -| `DeveloperBillingOptionParams` | Configures developer-billing support on `BillingClient` | +| `DeveloperBillingOptionParams` | Configures developer billing on `BillingFlowParams` | | `DeveloperProvidedBillingListener` | Callback when user picks developer-provided billing | -| `DeveloperProvidedBillingDetails` | Billing details to report back for reconciliation | -| `BillingOption.EXTERNAL_PAYMENTS` | Purchase-flow flag requesting external payments | +| `DeveloperProvidedBillingDetails` | Nullable token/link/original-ID fields plus selected products | +| `BillingClient.BillingProgram.EXTERNAL_PAYMENTS` | External Payments program constant | + +> **OpenIAP Note**: Exposed through `enableBillingProgramAndroid`, +> `developerBillingOption`, and the developer-provided billing listener. +> Enrolment with Google Play's External Payments program is required; +> availability is currently restricted to Japan. Horizon and Amazon do not +> implement this Google Play program. + +## Billing Choice (9.1+) + +Billing Library 9.1 adds APIs for markets and programs where either Google Play +or the app renders a billing choice screen. + +### Integration Scenarios + +| Scenario | Choice renderer | Developer payment | BillingClient setup | Required flow | +|----------|-----------------|-------------------|---------------------|---------------| +| 1A | Google | In app | `EnableBillingProgramParams` with `DeveloperProvidedBillingListener` | Pass a minimal `DeveloperBillingOptionParams`; Play returns the token through the listener | +| 1B | Developer | In app | `EnableBillingProgramParams` without the listener | Fetch choice info, create an `IN_APP` token, show the information dialog, then render the choice UI | +| 2A | Google | External link | `EnableBillingProgramParams` with `DeveloperProvidedBillingListener` | Create an `EXTERNAL_LINK` token and pass it with the URI through `DeveloperBillingOptionParams` | +| 2B | Developer | External link | `EnableBillingProgramParams` without the listener | Fetch choice info, create an `EXTERNAL_LINK` token, render the choice UI, then pass the token to `launchExternalLink` | + +The setup must match `choiceScreenType` from Play Console. Registering the +listener in a developer-rendered integration is not equivalent to omitting it. + +| API / Type | Purpose | +|------------|---------| +| `BillingClient.getBillingChoiceInfoAsync()` | Fetches billing choices available to the current user | +| `BillingChoiceInfo` | Contains choice-screen data, including image URLs and loyalty details | +| `GetBillingChoiceInfoParams` | Configures the billing-choice info request | +| `BillingClient.showBillingProgramInformationDialog()` | Shows an information dialog for a billing program | +| `BillingProgramInformationDialogParams` | Configures the information dialog | +| `LaunchExternalLinkParams.setExternalTransactionToken()` | Supplies the pre-generated token for a developer-rendered external-link flow | +| `BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails` | Returns choice-screen type and external-link availability | +| `DeveloperBillingOptionParams` | Selects in-app or external-link developer billing during purchase | +| `BillingProgramReportingDetailsParams.DeveloperBillingType` | Distinguishes `IN_APP` and `EXTERNAL_LINK` reporting | + +### Developer Billing Purchase Options + +Only `billingProgram` is required for an in-app Billing Choice flow: + +```kotlin +val inAppChoice = DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .build() +``` + +For a Google-rendered external-link flow, also set the URI, launch mode, and the +pre-generated `EXTERNAL_LINK` transaction token: + +```kotlin +val externalLinkChoice = DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setLinkUri(Uri.parse("https://example.com/checkout")) + .setLaunchMode(DeveloperBillingOptionParams.LaunchMode.CALLER_WILL_LAUNCH_LINK) + .setExternalTransactionToken(preGeneratedToken) + .build() +``` + +### Developer-Rendered Choice Information + +```kotlin +val params = GetBillingChoiceInfoParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setPlayBillingChoiceImageLayout( + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_FOUR_BY_ONE + ) + .setUserLocale(Locale.forLanguageTag("en-US")) + .build() + +billingClient.getBillingChoiceInfoAsync(params) { result, info -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + val imageUrl = info.playBillingChoiceImageUrl + val loyaltyText = info.playBillingLoyaltyInfo + } +} +``` + +Supported image layouts are `RECTANGULAR_FOUR_BY_ONE`, +`RECTANGULAR_THREE_BY_ONE`, and `RECTANGULAR_TWO_BY_TWO`. + +### Availability Details + +For `BILLING_CHOICE`, `BillingProgramAvailabilityDetails` can include: + +| Field | Meaning | +|-------|---------| +| `choiceScreenType` | `UNSPECIFIED`, `DEVELOPER_RENDERED`, or `GOOGLE_RENDERED` | +| `isExternalLinkAvailable` | Whether the user is eligible for an external-link option | + +### Information Dialog + +For developer-rendered in-app choice (scenario 1B), call +`showBillingProgramInformationDialog()` before showing the app's choice UI. It +is a UI-thread API and returns through its listener; it does not return a +synchronous `BillingResult`: + +```kotlin +val params = BillingProgramInformationDialogParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setExternalTransactionToken(externalTransactionToken) + .build() + +billingClient.showBillingProgramInformationDialog(activity, params) { result -> + // Continue according to result.responseCode. +} +``` + +### Developer-Billed Subscription Replacement + +Use the original external transaction ID instead of an old Play purchase token +when replacing a subscription bought through developer billing: + +```kotlin +val updateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOriginalExternalTransactionId(originalExternalTransactionId) + .build() +``` + +> **OpenIAP Note**: OpenIAP exposes these through `BILLING_CHOICE`, +> `getBillingChoiceInfoAndroid`, `showBillingProgramInformationDialogAndroid`, +> `launchExternalLinkAndroid`, `developerBillingOption`, +> `originalExternalTransactionId`, and the expanded developer-provided billing +> callback. Set `InitConnectionConfig.billingChoiceScreenTypeAndroid` to +> `GOOGLE_RENDERED` (default) or `DEVELOPER_RENDERED` so OpenIAP includes or +> omits the listener correctly. Play-only APIs return unsupported/default +> behavior on Horizon and Amazon. + +## In-App Billing Messages (4.1+) + +`showInAppMessages()` must run on the UI thread. It returns a synchronous +`BillingResult` for submission errors and reports the user interaction through +`InAppMessageResponseListener`. + +## PBL 9 Migration Guardrails -> **OpenIAP Note**: Exposed through the Android-specific `AlternativeBilling*` surface in OpenIAP. Enrolment with Google Play's External Payments program is required; availability is currently restricted to Japan. The Horizon flavor does NOT implement this. +- Replace removed APIs: `SkuDetails`, `SkuDetailsParams`, `SkuDetailsResponseListener`, `BillingClient.SkuType`, `querySkuDetailsAsync()`, no-argument `enablePendingPurchases()`, and string `queryPurchasesAsync()`. +- Use `ProductDetails`, `QueryProductDetailsParams`, `BillingClient.ProductType`, parameterized `enablePendingPurchases(PendingPurchasesParams)`, and `queryPurchasesAsync(QueryPurchasesParams, ...)`. +- Handle `DeveloperProvidedBillingDetails.getExternalTransactionToken()`, + `getLinkUri()`, and `getOriginalExternalTransactionId()` as nullable. +- Preserve every `DeveloperProvidedBillingDetails.Product` (`id`, `type`, and + nullable `offerToken`) from the callback. +- Keep Horizon shared code on the Billing 7.0-compatible API subset; put PBL 8/9 code in Play-only sources or behind reflection. ## Best Practices 1. **Always acknowledge purchases** within 3 days or they will be refunded 2. **Verify purchases server-side** using Google Play Developer API 3. **Handle pending purchases** for payment methods that require additional steps -4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+) +4. **Auto-reconnect is enabled by default** in OpenIAP when available (8.0+) 5. **Check product status codes** (8.0+) to understand why products weren't fetched 6. **Check isSuspended** (8.1+) before granting entitlements -7. **Cache product details** to avoid repeated queries +7. **Distinguish in-app and external-link Billing Choice** when configuring and reporting developer billing +8. **Cache product details** to avoid repeated queries diff --git a/knowledge/external/horizon-api.md b/knowledge/external/horizon-api.md index 309e8d538..07e902f6a 100644 --- a/knowledge/external/horizon-api.md +++ b/knowledge/external/horizon-api.md @@ -14,18 +14,32 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two | Library | Version | Compatible With | |---------|---------|-----------------| -| horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API | -| Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A | +| horizon-billing-compatibility | **2.0.0** (latest) | Google Play Billing **7.0** API | +| Google Play Billing (upstream latest) | **9.1.0** | N/A | +| Google Play Billing (OpenIAP Play flavor) | **9.1.0** | N/A | | react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ | | expo-iap | latest | Billing 7.0+, Kotlin 2.0+ | -**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. +**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x or 9.x. When writing shared code for both Play and Horizon flavors: -- Use only APIs that exist in **both** Billing 7.0 and 8.x -- Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended` +- Use only APIs that exist in **both** Billing 7.0 and the Play-flavor Billing version +- Horizon SDK does NOT support Billing 8.x/9.x features like auto-reconnect, product status codes, `includeSuspended`, or Billing Choice - OpenIAP handles this automatically with flavor-specific implementations +### Latest Horizon Billing Release + +Meta released Horizon Billing Compatibility Library **2.0.0** on 2026-01-06. +The release notes call out a fix for querying subscription purchases with a +single billing plan and dependencies on Horizon Platform SDK Kotlin +`iap-kotlin` 0.2.0 and `core-kotlin` 0.2.0. + +OpenIAP uses `horizon-billing-compatibility` 2.0.0 and its transitive Horizon +Platform Kotlin SDK modules. The Horizon flavor is compiled and tested +separately, including subscription-purchase restore coverage. Play Billing +9.1.0 features remain Play-flavor-only because Horizon compatibility still +targets the Billing 7.0 API surface. + ### APIs Available in Both (Safe to use in shared code) - `BillingClient.Builder`, `BillingClient.newBuilder()` @@ -47,6 +61,12 @@ When writing shared code for both Play and Horizon flavors: - Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+) - External Payments / Developer Billing Options (8.3+) +### APIs Only in Billing 9.x (DO NOT use in shared code) + +- Billing Choice information APIs (`getBillingChoiceInfoAsync`, `BillingChoiceInfo`, `ChoiceScreenType`) +- Billing-program information dialog APIs (`showBillingProgramInformationDialog`) +- PBL 9 migration assumptions around APIs removed from the Billing 7.0 surface + ## Billing Compatibility SDK For apps already using Google Play Billing Library, the Horizon Billing Compatibility SDK provides a minimal migration path. @@ -80,6 +100,10 @@ import com.meta.horizon.billingclient.api.* ### Important Notes +- Horizon Billing Compatibility 2.x reads the app id from Android manifest + meta-data key `com.meta.horizon.platform.HORIZON_APP_ID`. The older + `com.meta.horizon.platform.ovr.OCULUS_APP_ID` key is deprecated; OpenIAP also + accepts it and other historical keys only for migration compatibility. - Keep SKUs on Meta Horizon Developer Center same as Google Play Console product IDs - Only call `consumeAsync()` on consumable items - `acknowledgePurchase()` is no-op - no acknowledgement requirements diff --git a/knowledge/external/storekit2-api.md b/knowledge/external/storekit2-api.md index 19adf86b0..4687fd700 100644 --- a/knowledge/external/storekit2-api.md +++ b/knowledge/external/storekit2-api.md @@ -2,7 +2,7 @@ This document provides external API reference for Apple's StoreKit 2 framework. -## iOS 18+ Features +## iOS 18+ / 26+ Features | Feature | iOS Version | Description | |---------|-------------|-------------| @@ -21,6 +21,11 @@ This document provides external API reference for Apple's StoreKit 2 framework. | JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format | | `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option | | `SubscriptionStatus` by Transaction ID | WWDC 2025 | `status(for: transactionID:)` | +| Monthly subscriptions with a 12-month commitment | iOS 26.4 / 26.5 SDK | Monthly billing option for annual auto-renewable subscriptions | +| Group purchases and volume purchasing | WWDC 2026 | Multi-seat auto-renewable subscriptions through StoreKit 2 and Apple Business / School Manager | +| Retention Messaging | WWDC 2026 | Cancellation-flow messaging and offers, including real-time server decisioning | +| Retention offer type | WWDC 2026 | Signed transaction / renewal info can report offer type `5` for retention offers | +| Offer codes for all IAP types | 2026 | Offer codes expand beyond auto-renewable subscriptions; IAP promo-code creation ends March 26, 2026 | ### WWDC 2025 Updates @@ -29,6 +34,14 @@ This document provides external API reference for Apple's StoreKit 2 framework. - **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option. - Both new purchase options are back-deployed to iOS 15. +### WWDC 2026 Updates + +- **Monthly subscriptions with a 12-month commitment**: iOS 26.5 SDK adds a monthly billing plan for one-year auto-renewable subscriptions. Customers can subscribe on iOS, iPadOS, macOS, tvOS, and visionOS 26.4+. +- **Group purchases and volume purchasing**: Auto-renewable subscriptions using StoreKit 2 can be sold to groups and organizations. In-app group purchases pass a requested seat count into the StoreKit purchase flow; Apple Business Manager and Apple School Manager handle volume purchasing. +- **Volume pricing**: App Store Connect can configure up to five seat-count price bands for larger subscription purchases. +- **Retention Messaging**: App Store Connect can show cancellation-flow retention messages and offers. Real-time Retention Messaging adds a server-to-server decision point and supports a switch-plan view for monthly subscriptions with a 12-month commitment. +- **Offer-code expansion**: Offer codes now support consumables, non-consumables, non-renewing subscriptions, and broader auto-renewable subscription scenarios. Starting March 26, 2026, App Store Connect no longer creates new promo codes for In-App Purchases. + ## appAccountToken A UUID that associates a purchase with a user account in your system. This property allows you to correlate App Store transactions with users in your backend. @@ -310,6 +323,43 @@ if let advancedInfo = product.advancedCommerceInfo { } ``` +## Monthly Subscriptions With 12-Month Commitment (iOS 26.4+) + +This billing plan lets customers pay monthly while committing to an annual +auto-renewable subscription. Apps need to compile with the iOS 26.5 SDK to +merchandise the plan, and customers can purchase on Apple platforms running +26.4 or later. + +```swift +let result = try await product.purchase(options: [ + .billingPlanType(.monthly) +]) +``` + +> **OpenIAP Note**: The schema represents this with +> `SubscriptionBillingPlanTypeIOS` and `RequestSubscriptionIOSProps.billingPlanType`. + +## Group Purchases and Volume Purchasing (WWDC 2026) + +StoreKit 2 auto-renewable subscriptions can be sold to multiple seats for +groups or organizations. Volume purchasing is handled by Apple Business Manager +and Apple School Manager. For in-app group purchases, the app starts a StoreKit +purchase with the requested seat count, then Apple can manage invitation links +and seat assignment unless the app integrates custom group management. + +> **OpenIAP gap**: No public OpenIAP request field exists yet for group-purchase +> seat count or StoreKit group-management identifiers. + +## Retention Messaging (WWDC 2026) + +Retention Messaging lets App Store Connect present messages and optional offers +when a subscriber is about to cancel. Real-time Retention Messaging can call a +server endpoint so the developer can choose the message, offer, or switch-plan +view at cancellation time. + +Signed transaction and renewal information can include a retention offer as +offer type `5`. + ## StoreKit Message API (iOS 18+) Listen for App Store–generated messages (billing issues, win-back offers, price increases, generic). diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index cd51437f4..c34afafd9 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -115,6 +115,14 @@ swift build # Verifies ObjC bridge compiles ## SDK Parity Checklist (CRITICAL — prevents "declared but not implemented") +### API version annotations + +For newly exposed platform features, public schema and API documentation must +name the OpenIAP versions first and the upstream SDK requirement second. Use the +format `OpenIAP Spec / openiap-google (requires Play Billing ++)`. Upstream-only labels such as `Billing 9.1.0+` do not tell OpenIAP +consumers which library release contains the API. + When the GraphQL schema in [`packages/gql`](../../packages/gql) adds or changes an API, the regenerated `types.*` files **declare** the handler but do not **implement** it. Every wrapper library must wire the new API end-to-end or users will see silent nulls, phantom interfaces (GitHub issue #104), or `UnsupportedOperationException` at runtime. The mechanical guardrail for this checklist is: @@ -167,13 +175,13 @@ GraphQL schema ─► generated types ─► public API ─► native bridge ─ For every new/changed handler in the generated types, verify **all five** of these per target library before considering the change shippable: -| Library | 1. Type declared | 2. Public API exposed | 3. Platform bridge | 4. Wired into handlers bundle | 5. Test coverage | -| -------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **react-native-iap** | `src/types.ts` (generated) | `src/index.ts` export (Nitro or composed TS) | `ios/HybridRnIap.swift` (iOS), `android/.../HybridRnIap.kt` (Android) | Not required (flat exports) | Mock stub in all 4 `mockIap` objects in `__tests__/` (per memory) | -| **expo-iap** | `src/types.ts` (generated) | `src/modules/ios.ts` / `android.ts` export, re-exported from `src/index.ts` | `ios/ExpoIapModule.swift` `AsyncFunction`, `android/.../ExpoIapModule.kt` | Not required (flat exports) | `src/modules/__tests__/*.test.ts` | -| **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift` and `macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | -| **kmp-iap** | `library/src/commonMain/.../openiap/Types.kt` (generated interface) | exposed via `KmpInAppPurchase` / `kmpIapInstance` | `library/src/iosMain/.../InAppPurchaseIOS.kt` — must call `openIapModule.WithCompletion { ... }`, **never** `throw UnsupportedOperationException` | Not required (interface dispatch) | `library/src/commonTest/` if testable cross-platform | -| **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | Manual testing — no automated test suite yet | +| Library | 1. Type declared | 2. Public API exposed | 3. Platform bridge | 4. Wired into handlers bundle | 5. Test coverage | +| -------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **react-native-iap** | `src/types.ts` (generated) | `src/index.ts` export (Nitro or composed TS) | `ios/HybridRnIap.swift` (iOS), `android/.../HybridRnIap.kt` (Android) | Not required (flat exports) | Mock stub in all 4 `mockIap` objects in `__tests__/` (per memory) | +| **expo-iap** | `src/types.ts` (generated) | `src/modules/ios.ts` / `android.ts` export, re-exported from `src/index.ts` | `ios/ExpoIapModule.swift` `AsyncFunction`, `android/.../ExpoIapModule.kt` | Not required (flat exports) | `src/modules/__tests__/*.test.ts` | +| **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift` and `macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | +| **kmp-iap** | `library/src/commonMain/.../openiap/Types.kt` (generated interface) | exposed via `KmpInAppPurchase` / `kmpIapInstance` | `library/src/iosMain/.../InAppPurchaseIOS.kt` — must call `openIapModule.WithCompletion { ... }`, **never** `throw UnsupportedOperationException` | Not required (interface dispatch) | `library/src/commonTest/` if testable cross-platform | +| **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | Manual testing — no automated test suite yet | | **maui-iap** | `src/OpenIap.Maui/Types.cs` (generated) | `OpenIap.QueryResolver` / `MutationResolver` interfaces in `Types.cs`; `IOpenIap` adds the listener-stream contract; static facade is `OpenIap.Maui.OpenIapClient` (`OpenIap.Maui.Iap` remains as a legacy shim); IAPKit helpers mirror TypeScript via `OpenIapClient.KitApi(...)`, `OpenIapClient.ConnectWebhookStream(...)`, `OpenIapClient.ParseWebhookEventData(...)`, and `OpenIapClient.WebhookEventTypes` | Android: `OpenIapMauiModule.kt` in `libraries/maui-iap/android/openiap/` (JSON-shaped Java facade over `packages/google`), bound by `OpenIap.Maui.Bindings.Android.csproj`, consumed by `Platforms/Android/OpenIapAndroid.cs`. Google Billing / Play Services / Gson / AndroidX / Kotlin dependencies must stay NuGet `PackageReference`s, not fat-bundled AARs. iOS / macCatalyst: existing `OpenIapModule+ObjC.swift` bridge in `packages/apple`, bound by hand-written `OpenIap.Maui.Bindings.iOS/ApiDefinition.cs`, consumed by `Platforms/iOS/OpenIapIOS.cs` (+ subclass `OpenIapMacCatalyst`). | Not required (interface dispatch) | Example app `libraries/maui-iap/example/OpenIap.Maui.Example` builds for net9.0-android / net9.0-ios / net9.0-maccatalyst; package CI builds net9/net10 shared, Android, iOS, and macCatalyst TFMs (manual device testing for purchase flow); no xUnit tests yet | ### Platform suffix rule (who needs what) @@ -293,21 +301,26 @@ The Google package supports **two build flavors**: | Flavor | Billing Library | Version | | ------- | ----------------------------- | -------------------------- | -| Play | Google Play Billing | 8.3.0 | -| Horizon | horizon-billing-compatibility | 1.1.1 (GPB 7.0 compatible) | +| Play | Google Play Billing | 9.1.0 | +| Horizon | horizon-billing-compatibility | 2.0.0 (GPB 7.0 compatible) | -**CRITICAL**: Horizon SDK implements **Billing 7.0 API**, not 8.x. When writing shared code in `src/main/`: +**CRITICAL**: Horizon SDK implements **Billing 7.0 API**, not 8.x/9.x. When writing shared code in `src/main/`: -**Safe APIs (exist in both 7.0 and 8.x):** +**Safe APIs (exist in both 7.0 and 9.x):** - `queryProductDetailsAsync()`, `launchBillingFlow()` - `acknowledgePurchase()`, `consumeAsync()`, `queryPurchasesAsync()` -**DO NOT use in shared code (8.x only):** +**DO NOT use in shared code (8.x/9.x only):** - `enableAutoServiceReconnection()` - Product-level status codes - One-time products with multiple offers +- Suspended-subscription queries and product-level replacement parameters +- Billing Programs APIs, including External Payments and Billing Choice + +Keep those APIs in `src/play/`. Billing Choice information, dialogs, choice +screen types, and developer-provided billing fields require Play Billing 9.1.0. ### Horizon-Specific APIs diff --git a/knowledge/internal/05-docs-patterns.md b/knowledge/internal/05-docs-patterns.md index cfebc742c..c7ae8a378 100644 --- a/knowledge/internal/05-docs-patterns.md +++ b/knowledge/internal/05-docs-patterns.md @@ -188,6 +188,25 @@ Framework implementation listings must be derived from Release notes are located at `packages/docs/src/pages/docs/updates/releases.tsx`. +### Package-specific grouping for shared releases + +The docs release page is the canonical release-note SSOT, including when many +packages ship together. To satisfy the package-specific changelog requirement +from issue #206 without duplicating release history across package-local files: + +- Audit the full requested commit range inclusively and include the current PR + diff before drafting the note. +- Group user-visible changes by affected platform package or framework library: + Google, Apple, IAPKit, React Native, Expo, Flutter, Godot, KMP, and MAUI. +- Omit packages with no user-visible change and keep each remaining group to the + smallest set of useful upgrade notes. +- Do not replace package-specific behavior with a generic "framework parity" + bullet when wrappers have different setup, runtime, or compatibility details. +- Exclude version-only commits, generated-file churn, and CI mechanics unless + they change how users install, build, or validate the release. +- Keep package-local changelogs as pointers to this page and GitHub Releases, + except where a package registry requires generated inline history. + ### Adding New Release Notes 1. Add new entry at the **top** of the `allNotes` array diff --git a/knowledge/internal/06-git-deployment.md b/knowledge/internal/06-git-deployment.md index 2eee8e089..3c74d6e4c 100644 --- a/knowledge/internal/06-git-deployment.md +++ b/knowledge/internal/06-git-deployment.md @@ -232,13 +232,16 @@ linking it. This prevents stale Package Releases tables such as documenting ### openiap-versions.json -**CRITICAL: NEVER manually edit `openiap-versions.json`** +**CRITICAL: NEVER manually edit the `google` or `apple` fields in +`openiap-versions.json`.** -This file is automatically managed by CI/CD workflows during releases: +Version ownership is split: - Apple releases update `apple` version - Google releases update `google` version -- GQL releases update `spec` version +- The shared spec can be bumped directly in a feature PR when the maintainer + explicitly requests the target version. Update both `spec` and + `packages/gql/package.json`, then run `./scripts/sync-versions.sh`. - Deploy script (`npm run deploy`) uses the current `spec` version by default, and updates `spec` only when an explicit version is passed @@ -248,12 +251,17 @@ The manifest is only for the shared spec and native platform packages: `kmp-iap`, `maui-iap`) must stay in each library's own package metadata and release workflow, not as extra keys in `openiap-versions.json`. -Manual edits will cause version conflicts and deployment issues. Always use the GitHub Actions workflows or deploy script to update versions. +Manual Google or Apple edits will cause version conflicts and deployment +issues. Use their GitHub Actions workflows. A direct spec edit is the explicit +exception above. **Why this matters:** If a feature PR sets `apple: "2.1.1"` manually, and then CI auto-bumps on release, CI sees "current is 2.1.1" and bumps to 2.1.2 — skipping 2.1.1 entirely. The published tag becomes 2.1.2 with no 2.1.1 ever existing. -**Rule:** Feature PRs must NEVER touch version fields in `openiap-versions.json`. Version bumps happen only via: +**Rule:** Feature PRs must never touch `google` or `apple`. Version bumps happen +via: 1. Release workflows (Apple Release, Google Release) -2. Deploy script (`npm run deploy`, optionally `npm run deploy `) -3. CI auto-bump after merge +2. A maintainer-requested direct `spec` bump paired with + `packages/gql/package.json` +3. Deploy script (`npm run deploy`, optionally `npm run deploy `) +4. CI auto-bump after merge where configured diff --git a/knowledge/internal/sandbox-subscription-billing-issue.md b/knowledge/internal/sandbox-subscription-billing-issue.md index 8e565a61e..5e58ace50 100644 --- a/knowledge/internal/sandbox-subscription-billing-issue.md +++ b/knowledge/internal/sandbox-subscription-billing-issue.md @@ -107,7 +107,7 @@ cd ../flutter_inapp_purchase && flutter analyze **Horizon flavor (do NOT attempt)** - The Horizon flavor's `addSubscriptionBillingIssueListener` is a documented no-op. Verified by - `SubscriptionBillingIssueHorizonNoOpTest` (Robolectric, runs on CI). There is no sandbox path on Horizon because the Billing Compatibility SDK 1.1.1 targets Play Billing 7.0 which does not expose `Purchase.isSuspended`. + `SubscriptionBillingIssueHorizonNoOpTest` (Robolectric, runs on CI). There is no sandbox path on Horizon because the Billing Compatibility SDK 2.0.0 targets Play Billing 7.0 which does not expose `Purchase.isSuspended`. --- diff --git a/libraries/expo-iap/README.md b/libraries/expo-iap/README.md index 43242b09d..78550f4d4 100644 --- a/libraries/expo-iap/README.md +++ b/libraries/expo-iap/README.md @@ -85,7 +85,7 @@ Expo IAP conforms to the **[OpenIAP specification](https://openiap.dev)** — an - **Shared specification** — Common types, error codes, and purchase flows across all platforms - **Generated type-safe bindings** — Swift, Kotlin, Dart, and GDScript from a single GraphQL schema -- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 8.x) +- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 9.1.0) - **Verification profiles** — Standardized receipt validation and purchase verification patterns Other libraries built on OpenIAP: [react-native-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap) · [flutter_inapp_purchase](https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase) · [kmp-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap) · [godot-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/godot-iap) diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt index 4434c3070..97a81893c 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt @@ -1,6 +1,7 @@ package expo.modules.iap import dev.hyo.openiap.AndroidSubscriptionOfferInput +import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapModule import dev.hyo.openiap.ProductQueryType @@ -102,7 +103,15 @@ object ExpoIapHelper { } } ?: emptyList() val purchaseToken = effective["purchaseToken"] as? String + val originalExternalTransactionId = effective["originalExternalTransactionId"] as? String val replacementMode = effective["replacementMode"] as? Number + val developerBillingOption = + (effective["developerBillingOption"] as? Map<*, *>)?.let { optionMap -> + val json = optionMap.entries.mapNotNull { (key, value) -> + (key as? String)?.let { it to value } + }.toMap() + DeveloperBillingOptionParamsAndroid.fromJson(json) + } val subscriptionProductReplacementParams = (effective["subscriptionProductReplacementParams"] as? Map<*, *>)?.let { paramsMap -> val oldProductId = paramsMap["oldProductId"] as? String @@ -128,6 +137,8 @@ object ExpoIapHelper { offerToken = offerToken, offerTokenArr = offerTokenArr, explicitSubscriptionOffers = explicitSubscriptionOffers, + developerBillingOption = developerBillingOption, + originalExternalTransactionId = originalExternalTransactionId, purchaseToken = purchaseToken, replacementMode = replacementMode, subscriptionProductReplacementParams = subscriptionProductReplacementParams, @@ -155,6 +166,8 @@ object ExpoIapHelper { val offerToken: String?, val offerTokenArr: List, val explicitSubscriptionOffers: List, + val developerBillingOption: DeveloperBillingOptionParamsAndroid?, + val originalExternalTransactionId: String?, val purchaseToken: String?, val replacementMode: Number?, val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid?, @@ -306,7 +319,7 @@ object ExpoIapHelper { "USER_CHOICE_BILLING", ) } - // Developer Provided Billing listener for External Payments (8.3.0+) + // Developer Provided Billing listener for External Payments (8.3.0+) and Billing Choice (9.1.0+) openIap.addDeveloperProvidedBillingListener { details -> safeEmitEvent( module, 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 4639a0f27..5ccaf9bb9 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 @@ -36,9 +36,15 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean +import dev.hyo.openiap.BillingChoiceImageLayoutAndroid as OpenIapBillingChoiceImageLayout +import dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid as OpenIapBillingProgramInformationDialogParams import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgram +import dev.hyo.openiap.DeveloperBillingTypeAndroid as OpenIapDeveloperBillingType import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunchMode import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType +import dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid as OpenIapGetBillingChoiceInfoParams +import dev.hyo.openiap.InAppMessageCategoryAndroid as OpenIapInAppMessageCategory +import dev.hyo.openiap.InAppMessageParamsAndroid as OpenIapInAppMessageParams import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams class ExpoIapModule : Module() { @@ -280,6 +286,8 @@ class ExpoIapModule : Module() { isOfferPersonalized = parsedParams.isOfferPersonalized, obfuscatedAccountId = parsedParams.obfuscatedAccountId, obfuscatedProfileId = parsedParams.obfuscatedProfileId, + developerBillingOption = parsedParams.developerBillingOption, + originalExternalTransactionId = parsedParams.originalExternalTransactionId, purchaseToken = parsedParams.purchaseToken, replacementMode = parsedParams.replacementMode?.toInt(), skus = parsedParams.skus, @@ -289,7 +297,7 @@ class ExpoIapModule : Module() { RequestPurchaseProps( request = RequestPurchaseProps.Request.Subscription( - RequestSubscriptionPropsByPlatforms(android = android), + RequestSubscriptionPropsByPlatforms(google = android), ), type = ProductQueryType.Subs, ) @@ -301,13 +309,14 @@ class ExpoIapModule : Module() { isOfferPersonalized = parsedParams.isOfferPersonalized, obfuscatedAccountId = parsedParams.obfuscatedAccountId, obfuscatedProfileId = parsedParams.obfuscatedProfileId, + developerBillingOption = parsedParams.developerBillingOption, offerToken = parsedParams.offerToken, skus = parsedParams.skus, ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Purchase( - RequestPurchasePropsByPlatforms(android = android), + RequestPurchasePropsByPlatforms(google = android), ), type = ProductQueryType.InApp, ) @@ -569,8 +578,10 @@ class ExpoIapModule : Module() { val result = openIapStore.isBillingProgramAvailable(openIapProgram) val response = mapOf( - "billingProgram" to program, + "billingProgram" to result.billingProgram.toJson(), + "choiceScreenType" to result.choiceScreenType?.toJson(), "isAvailable" to result.isAvailable, + "isExternalLinkAvailable" to result.isExternalLinkAvailable, ) ExpoIapLog.result("isBillingProgramAvailableAndroid", response) promise.resolve(response) @@ -581,12 +592,41 @@ class ExpoIapModule : Module() { } } - AsyncFunction("createBillingProgramReportingDetailsAndroid") { program: String, promise: Promise -> - ExpoIapLog.payload("createBillingProgramReportingDetailsAndroid", mapOf("program" to program)) + AsyncFunction("getBillingChoiceInfoAndroid") { params: Map, promise: Promise -> + ExpoIapLog.payload("getBillingChoiceInfoAndroid", params) + scope.launch { + try { + val request = + OpenIapGetBillingChoiceInfoParams( + billingProgram = mapBillingProgram(params["billingProgram"] as? String ?: "billing-choice"), + playBillingChoiceImageLayout = mapBillingChoiceImageLayout( + params["playBillingChoiceImageLayout"] as? String ?: "rectangular-four-by-one", + ), + userLocale = params["userLocale"] as? String, + ) + val result = openIapStore.getBillingChoiceInfo(request) + ExpoIapLog.result("getBillingChoiceInfoAndroid", mapOf("hasImageUrl" to result.playBillingChoiceImageUrl.isNotBlank())) + promise.resolve(result.toJson()) + } catch (e: Exception) { + ExpoIapLog.failure("getBillingChoiceInfoAndroid", e) + promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + } + } + } + + AsyncFunction("createBillingProgramReportingDetailsAndroid") { program: String, developerBillingType: String?, promise: Promise -> + ExpoIapLog.payload( + "createBillingProgramReportingDetailsAndroid", + mapOf("program" to program, "developerBillingType" to developerBillingType), + ) scope.launch { try { val openIapProgram = mapBillingProgram(program) - val result = openIapStore.createBillingProgramReportingDetails(openIapProgram) + val result = + openIapStore.createBillingProgramReportingDetails( + openIapProgram, + mapDeveloperBillingType(developerBillingType), + ) val response = mapOf( "billingProgram" to program, @@ -604,6 +644,74 @@ class ExpoIapModule : Module() { } } + AsyncFunction("showBillingProgramInformationDialogAndroid") { params: Map, promise: Promise -> + ExpoIapLog.payload("showBillingProgramInformationDialogAndroid", params) + scope.launch { + try { + val activity = + runCatching { currentActivity } + .onFailure { + ExpoIapLog.failure("showBillingProgramInformationDialogAndroid activity", it) + }.getOrNull() ?: run { + promise.reject(OpenIapError.ServiceUnavailable.CODE, "Activity not available", null) + return@launch + } + val token = params["externalTransactionToken"] as? String + if (token.isNullOrBlank()) { + promise.reject(OpenIapError.DeveloperError.CODE, "`externalTransactionToken` is a required parameter.", null) + return@launch + } + val result = + openIapStore.showBillingProgramInformationDialog( + activity, + OpenIapBillingProgramInformationDialogParams( + billingProgram = mapBillingProgram(params["billingProgram"] as? String ?: "billing-choice"), + externalTransactionToken = token, + ), + ) + ExpoIapLog.result("showBillingProgramInformationDialogAndroid", result.toJson()) + promise.resolve(result.toJson()) + } catch (e: Exception) { + ExpoIapLog.failure("showBillingProgramInformationDialogAndroid", e) + promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + } + } + } + + AsyncFunction("showInAppMessagesAndroid") { params: Map?, promise: Promise -> + ExpoIapLog.payload("showInAppMessagesAndroid", params ?: emptyMap()) + scope.launch { + try { + val activity = + runCatching { currentActivity } + .onFailure { + ExpoIapLog.failure("showInAppMessagesAndroid activity", it) + }.getOrNull() ?: run { + promise.reject(OpenIapError.ServiceUnavailable.CODE, "Activity not available", null) + return@launch + } + val categories = + (params?.get("categories") as? List<*>) + ?.map { entry -> + val category = entry as? String + ?: throw IllegalArgumentException("In-app message category must be a string: $entry") + mapInAppMessageCategory(category) + ?: throw IllegalArgumentException("Unknown in-app message category: $category") + } + val result = + openIapStore.showInAppMessages( + activity, + OpenIapInAppMessageParams(categories = categories), + ) + ExpoIapLog.result("showInAppMessagesAndroid", result.toJson()) + promise.resolve(result.toJson()) + } catch (e: Exception) { + ExpoIapLog.failure("showInAppMessagesAndroid", e) + promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + } + } + } + AsyncFunction("launchExternalLinkAndroid") { params: Map, promise: Promise -> ExpoIapLog.payload("launchExternalLinkAndroid", params) scope.launch { @@ -618,6 +726,7 @@ class ExpoIapModule : Module() { } val billingProgram = params["billingProgram"] as? String + val externalTransactionToken = params["externalTransactionToken"] as? String val launchMode = params["launchMode"] as? String ?: "unspecified" val linkType = params["linkType"] as? String ?: "unspecified" val linkUri = params["linkUri"] as? String @@ -635,6 +744,7 @@ class ExpoIapModule : Module() { val openIapParams = OpenIapLaunchExternalLinkParams( billingProgram = mapBillingProgram(billingProgram), + externalTransactionToken = externalTransactionToken, launchMode = mapExternalLinkLaunchMode(launchMode), linkType = mapExternalLinkType(linkType), linkUri = linkUri, @@ -666,9 +776,31 @@ class ExpoIapModule : Module() { "external-content-link" -> OpenIapBillingProgram.ExternalContentLink "external-payments" -> OpenIapBillingProgram.ExternalPayments "user-choice-billing" -> OpenIapBillingProgram.UserChoiceBilling + "billing-choice" -> OpenIapBillingProgram.BillingChoice else -> OpenIapBillingProgram.Unspecified } + private fun mapBillingChoiceImageLayout(layout: String): OpenIapBillingChoiceImageLayout = + when (layout) { + "rectangular-three-by-one" -> OpenIapBillingChoiceImageLayout.RectangularThreeByOne + "rectangular-two-by-two" -> OpenIapBillingChoiceImageLayout.RectangularTwoByTwo + else -> OpenIapBillingChoiceImageLayout.RectangularFourByOne + } + + private fun mapDeveloperBillingType(type: String?): OpenIapDeveloperBillingType? = + when (type) { + "in-app" -> OpenIapDeveloperBillingType.InApp + "external-link" -> OpenIapDeveloperBillingType.ExternalLink + else -> null + } + + private fun mapInAppMessageCategory(category: String): OpenIapInAppMessageCategory? = + when (category) { + "unknown-in-app-message-category-id" -> OpenIapInAppMessageCategory.UnknownInAppMessageCategoryId + "transactional" -> OpenIapInAppMessageCategory.Transactional + else -> null + } + private fun mapExternalLinkLaunchMode(mode: String): OpenIapExternalLinkLaunchMode = when (mode) { "launch-in-external-browser-or-app" -> OpenIapExternalLinkLaunchMode.LaunchInExternalBrowserOrApp diff --git a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx index 5ce6bc2a5..4caf4e4e7 100644 --- a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {render, fireEvent, waitFor} from '@testing-library/react-native'; +import {act, render, fireEvent, waitFor} from '@testing-library/react-native'; import {Alert, Platform} from 'react-native'; // Mock expo-constants @@ -21,6 +21,20 @@ const mockRequestPurchase = jest.fn().mockResolvedValue(undefined); const mockFinishTransaction = jest.fn(); const mockGetActiveSubscriptions = jest.fn(); const mockGetAvailablePurchases = jest.fn().mockResolvedValue([]); +const mockVerifyPurchase = jest.fn().mockResolvedValue({}); +const mockVerifyPurchaseWithProvider = jest + .fn((_request: unknown) => + Promise.resolve({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'google', + }, + }), + ) + .mockName('verifyPurchaseWithProvider'); +let mockOnPurchaseSuccess: + ((purchase: Record) => Promise | void) | undefined; const createMockSubscription = (overrides = {}) => ({ id: 'test.subscription.1', @@ -67,7 +81,10 @@ const mockUseIAP = jest.fn(); jest.mock('../../src', () => ({ initConnection: mockInitConnection, requestPurchase: mockRequestPurchase, - useIAP: () => mockUseIAP(), + useIAP: (options?: {onPurchaseSuccess?: typeof mockOnPurchaseSuccess}) => { + mockOnPurchaseSuccess = options?.onPurchaseSuccess; + return mockUseIAP(); + }, })); const SubscriptionFlow = require('../app/subscription-flow').default; @@ -85,6 +102,15 @@ describe('SubscriptionFlow Component', () => { mockGetActiveSubscriptions.mockResolvedValue([]); mockFinishTransaction.mockResolvedValue(undefined); mockGetAvailablePurchases.mockResolvedValue([]); + mockVerifyPurchase.mockResolvedValue({}); + mockVerifyPurchaseWithProvider.mockResolvedValue({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'google', + }, + }); + mockOnPurchaseSuccess = undefined; // Default mock implementation mockUseIAP.mockReturnValue({ @@ -96,6 +122,8 @@ describe('SubscriptionFlow Component', () => { finishTransaction: mockFinishTransaction, getActiveSubscriptions: mockGetActiveSubscriptions, activeSubscriptions: [], + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, }); }); @@ -339,4 +367,35 @@ describe('SubscriptionFlow Component', () => { // When there are no active subscriptions but connected, show check status link expect(getByText('Check Status')).toBeDefined(); }); + + it('re-verifies the Android IAPKit snapshot after finishing', async () => { + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + + await renderConnectedSubscriptionFlow(); + + await act(async () => { + await mockOnPurchaseSuccess?.({ + id: 'transaction-android-1', + platform: 'android', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'android-token', + transactionDate: Date.now(), + }); + }); + + expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect(mockVerifyPurchaseWithProvider.mock.calls[1]?.[0]).toEqual( + mockVerifyPurchaseWithProvider.mock.calls[0]?.[0], + ); + expect( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[0], + ).toBeLessThan(mockFinishTransaction.mock.invocationCallOrder[0]!); + expect(mockFinishTransaction.mock.invocationCallOrder[0]).toBeLessThan( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1]!, + ); + }); }); diff --git a/libraries/expo-iap/example/app/subscription-flow.tsx b/libraries/expo-iap/example/app/subscription-flow.tsx index 3b823a13f..bf61d0608 100644 --- a/libraries/expo-iap/example/app/subscription-flow.tsx +++ b/libraries/expo-iap/example/app/subscription-flow.tsx @@ -54,7 +54,8 @@ const getSubscriptionTier = (productId: string): number => { function isSubscriptionFlowProduct(productId: string): boolean { return SUBSCRIPTION_PRODUCT_IDS.some( (subscriptionId) => - productId === subscriptionId || productId.startsWith(`${subscriptionId}.`), + productId === subscriptionId || + productId.startsWith(`${subscriptionId}.`), ); } @@ -1620,9 +1621,9 @@ function SubscriptionFlowContainer() { const normalizedPurchaseStore = purchaseStore?.toLowerCase() ?? ''; const hasAndroidPurchaseIdentity = Boolean( purchase.purchaseToken || - purchase.id || - purchase.transactionId || - purchase.productId, + purchase.id || + purchase.transactionId || + purchase.productId, ); if (Platform.OS === 'ios' && purchasePlatform === 'ios') { @@ -1672,7 +1673,10 @@ function SubscriptionFlowContainer() { console.log('Android Purchase Analysis:'); console.log(' platform:', purchasePlatform || Platform.OS); console.log(' store:', normalizedPurchaseStore || 'unknown'); - console.log(' hasAndroidPurchaseIdentity:', hasAndroidPurchaseIdentity); + console.log( + ' hasAndroidPurchaseIdentity:', + hasAndroidPurchaseIdentity, + ); console.log(' isPurchased:', isPurchased); console.log(' isRestoration:', isRestoration); } @@ -1701,9 +1705,7 @@ function SubscriptionFlowContainer() { console.log( '[SubscriptionFlow] This is a restoration, skipping verification', ); - setPurchaseResult( - 'Subscription restored; finishing transaction...', - ); + setPurchaseResult('Subscription restored; finishing transaction...'); // Step 6: finish transaction (restoration) const finishCleanupKey = getPurchaseCleanupKey(purchase); @@ -1754,6 +1756,7 @@ function SubscriptionFlowContainer() { // Android: Google Play Developer API + RTDN // ------------------------------------------------------------ const currentVerificationMethod = verificationMethodRef.current; + let iapkitVerifyRequest: VerifyPurchaseWithProviderProps | null = null; console.log('[SubscriptionFlow] About to verify purchase:', { verificationMethod: currentVerificationMethod, productId, @@ -1809,6 +1812,7 @@ function SubscriptionFlowContainer() { provider: 'iapkit', iapkit: iapkitPayload, }; + iapkitVerifyRequest = verifyRequest; const iapkitLogPayload = { ...iapkitPayload, ...(iapkitPayload.apiKey ? {apiKey: '***hidden***'} : {}), @@ -1817,12 +1821,12 @@ function SubscriptionFlowContainer() { console.log( '[SubscriptionFlow] Sending IAPKit verification request:', JSON.stringify( - { - provider: verifyRequest.provider, - iapkit: iapkitLogPayload, - }, - null, - 2, + { + provider: verifyRequest.provider, + iapkit: iapkitLogPayload, + }, + null, + 2, ), ); @@ -1883,6 +1887,21 @@ function SubscriptionFlowContainer() { } if (didFinishTransaction) { + if (Platform.OS === 'android' && iapkitVerifyRequest) { + try { + const refreshedResult = + await verifyPurchaseWithProvider(iapkitVerifyRequest); + console.log( + '[SubscriptionFlow] IAPKit state after finishTransaction:', + refreshedResult, + ); + } catch (error) { + console.log( + '[SubscriptionFlow] IAPKit post-finish verification failed:', + error, + ); + } + } showNativeAlert('Success', 'New subscription activated successfully!'); console.log('✅ New subscription purchase completed'); } diff --git a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts index 689432de8..9ee7d60c4 100644 --- a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts +++ b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts @@ -403,10 +403,22 @@ describe('android configuration', () => { 'meta-data': [ { $: { - 'android:name': 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'android:name': 'com.meta.horizon.platform.HORIZON_APP_ID', 'android:value': '123', }, }, + { + $: { + 'android:name': 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'android:value': 'legacy', + }, + }, + { + $: { + 'android:name': 'com.oculus.vr.APP_ID', + 'android:value': 'legacy', + }, + }, { $: { 'android:name': 'dev.iapkit.API_KEY', @@ -440,7 +452,42 @@ describe('android configuration', () => { expect(manifest.manifest.application?.[0]?.['meta-data']).toEqual([ { $: { - 'android:name': 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'android:name': 'com.meta.horizon.platform.HORIZON_APP_ID', + 'android:value': '123', + }, + }, + ]); + }); + + it('migrates legacy Horizon App ID metadata to the canonical key', () => { + const manifest = { + manifest: { + application: [ + { + 'meta-data': [ + { + $: { + 'android:name': 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'android:value': 'old', + }, + }, + { + $: { + 'android:name': 'com.oculus.vr.APP_ID', + 'android:value': 'old', + }, + }, + ], + }, + ], + }, + }; + + expect(syncHorizonAppIdMetaData(manifest, true, '123')).toBe('updated'); + expect(manifest.manifest.application[0]!['meta-data']).toEqual([ + { + $: { + 'android:name': 'com.meta.horizon.platform.HORIZON_APP_ID', 'android:value': '123', }, }, @@ -473,7 +520,7 @@ describe('android configuration', () => { }, { $: { - 'android:name': 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'android:name': 'com.meta.horizon.platform.HORIZON_APP_ID', 'android:value': '123', }, }, @@ -500,7 +547,7 @@ describe('local OpenIAP configuration', () => { describe('ios module selection', () => { const createConfig = (ios?: ExpoConfig['ios']): ExpoConfig => - ({name: 'test-app', slug: 'test-app', ios}) as ExpoConfig; + ({name: 'test-app', slug: 'test-app', ios} as ExpoConfig); it('defaults to Expo IAP only when no options provided', () => { const result = resolveModuleSelection(createConfig(), undefined); diff --git a/libraries/expo-iap/plugin/src/withIAP.ts b/libraries/expo-iap/plugin/src/withIAP.ts index 5da9a2837..4787229d8 100644 --- a/libraries/expo-iap/plugin/src/withIAP.ts +++ b/libraries/expo-iap/plugin/src/withIAP.ts @@ -58,7 +58,20 @@ const addLineToGradle = ( }; const HORIZON_APP_ID_META_DATA_NAME = - 'com.meta.horizon.platform.ovr.OCULUS_APP_ID'; + 'com.meta.horizon.platform.HORIZON_APP_ID'; +const LEGACY_HORIZON_APP_ID_META_DATA_NAMES = new Set([ + 'com.meta.horizon.platform.ovr.OCULUS_APP_ID', + 'com.meta.horizon.platform.ovr.HORIZON_APP_ID', + 'com.oculus.vr.APP_ID', +]); + +const isHorizonAppIdMetaData = (metaData: any): boolean => { + const name = metaData?.$?.['android:name']; + return ( + name === HORIZON_APP_ID_META_DATA_NAME || + LEGACY_HORIZON_APP_ID_META_DATA_NAMES.has(name) + ); +}; type AndroidManifestLike = { manifest: { @@ -147,7 +160,7 @@ export function syncHorizonAppIdMetaData( if (!Array.isArray(existingMetaData)) return 'unchanged'; const nextMetaData = existingMetaData.filter( - (m) => m.$?.['android:name'] !== HORIZON_APP_ID_META_DATA_NAME, + (metaData) => !isHorizonAppIdMetaData(metaData), ); if (nextMetaData.length === existingMetaData.length || !application) { return 'unchanged'; @@ -179,17 +192,12 @@ export function syncHorizonAppIdMetaData( }, }; - const existingIndex = metaData.findIndex( - (m: any) => m.$?.['android:name'] === HORIZON_APP_ID_META_DATA_NAME, + const hadExistingAppId = metaData.some(isHorizonAppIdMetaData); + horizonApplication['meta-data'] = metaData.filter( + (item: any) => !isHorizonAppIdMetaData(item), ); - - if (existingIndex !== -1) { - metaData[existingIndex] = horizonAppIdMeta; - return 'updated'; - } - - metaData.push(horizonAppIdMeta); - return 'added'; + horizonApplication['meta-data'].push(horizonAppIdMeta); + return hadExistingAppId ? 'updated' : 'added'; } export const modifyAppBuildGradle = ( @@ -238,8 +246,9 @@ export const modifyAppBuildGradle = ( let flavor: 'amazon' | 'horizon' | 'play' = 'play'; let artifactId: - 'openiap-google-amazon' | 'openiap-google-horizon' | 'openiap-google' = - 'openiap-google'; + | 'openiap-google-amazon' + | 'openiap-google-horizon' + | 'openiap-google' = 'openiap-google'; if (isFireOsEnabled) { flavor = 'amazon'; artifactId = 'openiap-google-amazon'; @@ -734,7 +743,8 @@ interface LegacyAmazonPlatformFlagOptions { } type AmazonPlatformFlagInput = - AmazonPlatformFlagOptions | LegacyAmazonPlatformFlagOptions; + | AmazonPlatformFlagOptions + | LegacyAmazonPlatformFlagOptions; function isEnvFlagEnabled(name: string): boolean { return process.env[name] === '1'; @@ -761,7 +771,7 @@ export function resolveAmazonPlatformFlags( isEnvFlagEnabled('EXPO_IAP_VEGA'); const isHorizonEnabled = isFireOsEnabled ? false - : (options?.modules?.horizon ?? isEnvFlagEnabled('EXPO_IAP_HORIZON')); + : options?.modules?.horizon ?? isEnvFlagEnabled('EXPO_IAP_HORIZON'); const isOnsideEnabled = options?.modules?.onside ?? isEnvFlagEnabled('EXPO_IAP_ONSIDE'); @@ -803,7 +813,8 @@ export function resolveModuleSelection( options?: ExpoIapPluginCommonOptions | void, ): ModuleSelectionResult { const normalizedOptions = (options ?? undefined) as - ExpoIapPluginCommonOptions | undefined; + | ExpoIapPluginCommonOptions + | undefined; const selection = normalizedOptions?.module ?? 'auto'; diff --git a/libraries/expo-iap/src/__mocks__/expo-modules-core.js b/libraries/expo-iap/src/__mocks__/expo-modules-core.js index 18e7737f5..564c14aae 100644 --- a/libraries/expo-iap/src/__mocks__/expo-modules-core.js +++ b/libraries/expo-iap/src/__mocks__/expo-modules-core.js @@ -50,8 +50,11 @@ const mockNativeModule = { createAlternativeBillingTokenAndroid: jest.fn(), // Billing Programs API (8.2.0+) isBillingProgramAvailableAndroid: jest.fn(), + getBillingChoiceInfoAndroid: jest.fn(), launchExternalLinkAndroid: jest.fn(), createBillingProgramReportingDetailsAndroid: jest.fn(), + showBillingProgramInformationDialogAndroid: jest.fn(), + showInAppMessagesAndroid: jest.fn(), addListener: jest.fn(), removeListener: jest.fn(), removeListeners: jest.fn(), diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index d380134c8..f4fc6c62f 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -401,6 +401,24 @@ describe('Public API (index.ts)', () => { expect(ExpoIapModule.initConnection).toHaveBeenCalled(); expect(ExpoIapModule.endConnection).toHaveBeenCalled(); }); + + it('forwards developer-rendered Billing Choice connection config', async () => { + (ExpoIapModule.initConnection as jest.Mock) = jest + .fn() + .mockResolvedValue(true); + + await expect( + initConnection({ + enableBillingProgramAndroid: 'billing-choice', + billingChoiceScreenTypeAndroid: 'developer-rendered', + }), + ).resolves.toBe(true); + + expect(ExpoIapModule.initConnection).toHaveBeenCalledWith({ + enableBillingProgramAndroid: 'billing-choice', + billingChoiceScreenTypeAndroid: 'developer-rendered', + }); + }); }); describe('fetchProducts', () => { @@ -735,6 +753,64 @@ describe('Public API (index.ts)', () => { ); }); + it('Android forwards minimal in-app Billing Choice options', async () => { + (Platform as any).OS = 'android'; + (ExpoIapModule.requestPurchase as jest.Mock) = jest + .fn() + .mockResolvedValue([]); + + await requestPurchase({ + request: { + google: { + skus: ['premium'], + developerBillingOption: {billingProgram: 'billing-choice'}, + }, + }, + type: 'in-app', + }); + + expect(ExpoIapModule.requestPurchase).toHaveBeenCalledWith( + expect.objectContaining({ + developerBillingOption: {billingProgram: 'billing-choice'}, + }), + ); + }); + + it('Android forwards Billing Choice subscription replacement fields', async () => { + (Platform as any).OS = 'android'; + (ExpoIapModule.requestPurchase as jest.Mock) = jest + .fn() + .mockResolvedValue([]); + + await requestPurchase({ + request: { + google: { + skus: ['premium_monthly'], + originalExternalTransactionId: 'original-external-id', + developerBillingOption: { + billingProgram: 'billing-choice', + externalTransactionToken: 'pre-generated-token', + launchMode: 'caller-will-launch-link', + linkUri: 'https://example.com/checkout', + }, + }, + }, + type: 'subs', + }); + + expect(ExpoIapModule.requestPurchase).toHaveBeenCalledWith( + expect.objectContaining({ + originalExternalTransactionId: 'original-external-id', + developerBillingOption: { + billingProgram: 'billing-choice', + externalTransactionToken: 'pre-generated-token', + launchMode: 'caller-will-launch-link', + linkUri: 'https://example.com/checkout', + }, + }), + ); + }); + it('Android subscription passes both legacy replacementMode and new subscriptionProductReplacementParams', async () => { (Platform as any).OS = 'android'; (ExpoIapModule.requestPurchase as jest.Mock) = jest diff --git a/libraries/expo-iap/src/__tests__/useIAP.test.tsx b/libraries/expo-iap/src/__tests__/useIAP.test.tsx index 8e59ba9c4..83d48791f 100644 --- a/libraries/expo-iap/src/__tests__/useIAP.test.tsx +++ b/libraries/expo-iap/src/__tests__/useIAP.test.tsx @@ -13,9 +13,11 @@ jest.mock('react-native', () => ({ /* eslint-disable import/first */ import * as React from 'react'; import * as ReactTestRenderer from 'react-test-renderer'; +import {Platform} from 'react-native'; import ExpoIapModule from '../ExpoIapModule'; import {ErrorCode} from '../types'; import {useIAP, UseIAPOptions} from '../useIAP'; +import * as AndroidApi from '../modules/android'; /* eslint-enable import/first */ // Suppress console output during tests @@ -59,6 +61,7 @@ const purchaseErrorEvent = 'purchase-error'; describe('useIAP hook', () => { beforeEach(() => { jest.clearAllMocks(); + (Platform as unknown as {OS: string}).OS = 'ios'; // Default mock for initConnection (ExpoIapModule.initConnection as jest.Mock) = jest .fn() @@ -71,6 +74,95 @@ describe('useIAP hook', () => { }); }); + describe('Android Billing Choice options', () => { + it('forwards renderer config and billing event callbacks', async () => { + (Platform as unknown as {OS: string}).OS = 'android'; + const listeners: Record void> = {}; + (ExpoIapModule.addListener as jest.Mock).mockImplementation( + (eventName: string, listener: (payload: unknown) => void) => { + listeners[eventName] = listener; + return {remove: jest.fn()}; + }, + ); + + const onUserChoiceBillingAndroid = jest.fn(); + const onDeveloperProvidedBillingAndroid = jest.fn(); + const onSubscriptionBillingIssue = jest.fn(); + + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create( + {}} + />, + ); + await flushPromises(); + }); + + expect(ExpoIapModule.initConnection).toHaveBeenCalledWith({ + enableBillingProgramAndroid: 'billing-choice', + billingChoiceScreenTypeAndroid: 'developer-rendered', + }); + + const userChoice = {externalTransactionToken: 'user-choice-token'}; + const developerProvided = { + externalTransactionToken: 'billing-choice-token', + products: [{id: 'premium', type: 'in-app'}], + }; + const billingIssue = {id: 'purchase-id', productId: 'subscription'}; + listeners['user-choice-billing-android']?.(userChoice); + listeners['developer-provided-billing-android']?.(developerProvided); + listeners['subscription-billing-issue']?.(billingIssue); + + expect(onUserChoiceBillingAndroid).toHaveBeenCalledWith(userChoice); + expect(onDeveloperProvidedBillingAndroid).toHaveBeenCalledWith( + developerProvided, + ); + expect(onSubscriptionBillingIssue).toHaveBeenCalledWith(billingIssue); + }); + + it('exposes all Billing Choice APIs through the hook', async () => { + (Platform as unknown as {OS: string}).OS = 'android'; + let hookResult: ReturnType | null = null; + + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create( + { + hookResult = hook; + }} + />, + ); + await flushPromises(); + }); + + expect(hookResult!.getBillingChoiceInfoAndroid).toBe( + AndroidApi.getBillingChoiceInfoAndroid, + ); + expect(hookResult!.isBillingProgramAvailableAndroid).toBe( + AndroidApi.isBillingProgramAvailableAndroid, + ); + expect(hookResult!.createBillingProgramReportingDetailsAndroid).toBe( + AndroidApi.createBillingProgramReportingDetailsAndroid, + ); + expect(hookResult!.launchExternalLinkAndroid).toBe( + AndroidApi.launchExternalLinkAndroid, + ); + expect(hookResult!.showBillingProgramInformationDialogAndroid).toBe( + AndroidApi.showBillingProgramInformationDialogAndroid, + ); + expect(hookResult!.showInAppMessagesAndroid).toBe( + AndroidApi.showInAppMessagesAndroid, + ); + }); + }); + describe('onError callback', () => { it('calls onError when fetchProducts fails', async () => { const mockError = new Error('Failed to query product'); diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index 76fc4a521..f7cb09170 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -65,7 +65,7 @@ const createService = (): jest.Mocked => notifyFulfillment: jest.fn(async () => ({ responseCode: 1, })), - }) as unknown as jest.Mocked; + } as unknown as jest.Mocked); describe('Amazon Vega Expo adapter', () => { it('initializes without fetching Amazon user data', async () => { @@ -223,8 +223,7 @@ describe('Amazon Vega Expo adapter', () => { try { const result = module.acknowledgePurchaseAndroid('receipt-1'); - await Promise.resolve(); - jest.advanceTimersByTime(1_000); + await jest.advanceTimersByTimeAsync(1_000); await expect(result).resolves.toBeUndefined(); expect(service.notifyFulfillment).toHaveBeenCalledTimes(2); @@ -237,6 +236,28 @@ describe('Amazon Vega Expo adapter', () => { } }); + it('times out Amazon Vega fulfillment without duplicating the request', async () => { + jest.useFakeTimers(); + const service = createService(); + service.notifyFulfillment.mockImplementationOnce( + () => new Promise(() => {}), + ); + const module = createExpoIapVegaModule(service); + + try { + const result = module.acknowledgePurchaseAndroid('receipt-1'); + const expectation = expect(result).rejects.toMatchObject({ + code: 'service-timeout', + }); + await jest.advanceTimersByTimeAsync(2_000); + + await expectation; + expect(service.notifyFulfillment).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + it('recovers fulfillable receipts after Amazon Vega purchase failures', async () => { const service = createService(); service.purchase.mockResolvedValueOnce({ diff --git a/libraries/expo-iap/src/index.kepler.ts b/libraries/expo-iap/src/index.kepler.ts index 3b4834e4d..a3c4e2f2d 100644 --- a/libraries/expo-iap/src/index.kepler.ts +++ b/libraries/expo-iap/src/index.kepler.ts @@ -272,9 +272,18 @@ export const createAlternativeBillingTokenAndroid: MutationField< export const isBillingProgramAvailableAndroid: MutationField< 'isBillingProgramAvailableAndroid' > = async () => unsupported('isBillingProgramAvailableAndroid'); +export const getBillingChoiceInfoAndroid: QueryField< + 'getBillingChoiceInfoAndroid' +> = async () => unsupported('getBillingChoiceInfoAndroid'); export const launchExternalLinkAndroid: MutationField< 'launchExternalLinkAndroid' > = async () => unsupported('launchExternalLinkAndroid'); export const createBillingProgramReportingDetailsAndroid: MutationField< 'createBillingProgramReportingDetailsAndroid' > = async () => unsupported('createBillingProgramReportingDetailsAndroid'); +export const showBillingProgramInformationDialogAndroid: MutationField< + 'showBillingProgramInformationDialogAndroid' +> = async () => unsupported('showBillingProgramInformationDialogAndroid'); +export const showInAppMessagesAndroid: MutationField< + 'showInAppMessagesAndroid' +> = async () => unsupported('showInAppMessagesAndroid'); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index fc7c4367d..bf67f1de3 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -58,8 +58,8 @@ export enum OpenIapEvent { PromotedProductIOS = 'promoted-product-ios', UserChoiceBillingAndroid = 'user-choice-billing-android', /** - * Fired when user selects developer billing in External Payments flow (Android 8.3.0+) - * Only available in Japan. Contains externalTransactionToken for reporting. + * Fired for External Payments (8.3.0+) and Billing Choice (9.1.0+) + * developer billing flows. Nullable fields depend on the selected flow. */ DeveloperProvidedBillingAndroid = 'developer-provided-billing-android', /** @@ -482,33 +482,29 @@ export const userChoiceBillingListenerAndroid = ( }; /** - * Android-only listener for Developer Provided Billing events (External Payments). - * This fires when a user selects the developer's payment option in the External Payments - * side-by-side choice dialog during purchase flow. + * Android-only listener for Developer Provided Billing events. + * This fires when a user selects the developer's option in an External Payments + * or Billing Choice purchase flow. * - * Requires Google Play Billing Library 8.3.0+ and is currently only available in Japan. + * Requires Google Play Billing Library 8.3.0+; Billing Choice fields require 9.1.0+. * - * @param listener - Callback function that receives the external transaction token + * @param listener - Callback that receives selected products and flow details * @returns EventSubscription that can be used to unsubscribe * * @example * ```typescript * const subscription = developerProvidedBillingListenerAndroid(async (details) => { - * console.log('User selected developer billing'); - * console.log('External transaction token received; send it to your backend without logging it.'); - * - * // Process payment with your payment gateway - * await processPaymentWithYourGateway(details.externalTransactionToken); - * - * // IMPORTANT: Report the token to Google Play within 24 hours - * await reportExternalTransactionToGoogle(details.externalTransactionToken); + * await processPaymentWithYourGateway(details.products, details.linkUri); + * if (details.externalTransactionToken) { + * await reportExternalTransactionToGoogle(details.externalTransactionToken); + * } * }); * * // Later, clean up * subscription.remove(); * ``` * - * @platform Android (8.3.0+, Japan only) + * @platform Android (8.3.0+; Billing Choice 9.1.0+) */ export const developerProvidedBillingListenerAndroid = ( listener: (details: DeveloperProvidedBillingDetailsAndroid) => void, @@ -566,7 +562,9 @@ export const subscriptionBillingIssueListener = ( * Initialize the store connection. Must be called before any other IAP API. * * @param config Optional connection config. Use `enableBillingProgramAndroid` (Android, - * Play Billing 8.2.0+) to opt into External Payments etc. iOS ignores Android-specific fields. + * Play Billing 8.2.0+) to opt into a billing program. For Billing Choice 9.1.0+, set + * `billingChoiceScreenTypeAndroid` to the renderer configured in Play Console. + * iOS ignores Android-specific fields. * @returns Promise resolving to `true` when the platform billing client is connected. * @throws When the platform billing client fails to initialize. * @@ -574,6 +572,10 @@ export const subscriptionBillingIssueListener = ( * ```ts * await initConnection(); * await initConnection({ enableBillingProgramAndroid: 'external-offer' }); + * await initConnection({ + * enableBillingProgramAndroid: 'billing-choice', + * billingChoiceScreenTypeAndroid: 'developer-rendered', + * }); * ``` * * @remarks When using `useIAP()`, connection is auto-managed on mount/unmount — @@ -974,6 +976,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( obfuscatedProfileId, isOfferPersonalized, offerToken, + developerBillingOption, } = normalizedRequest; const result = (await ExpoIapModule.requestPurchase({ @@ -984,6 +987,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( obfuscatedAccountId: obfuscatedAccountId, obfuscatedProfileId: obfuscatedProfileId, offerToken: offerToken, + developerBillingOption: developerBillingOption ?? undefined, offerTokenArr: [], isOfferPersonalized: isOfferPersonalized ?? false, })) as Purchase[]; @@ -1020,6 +1024,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( subscriptionOffers, replacementMode: replacementModeInput, purchaseToken: purchaseTokenInput, + originalExternalTransactionId, + developerBillingOption, subscriptionProductReplacementParams, } = normalizedRequest; @@ -1031,6 +1037,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( type: native, skuArr: skus, purchaseToken, + originalExternalTransactionId: + originalExternalTransactionId ?? undefined, replacementMode, obfuscatedAccountId: obfuscatedAccountId, obfuscatedProfileId: obfuscatedProfileId, @@ -1039,6 +1047,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ), subscriptionOffers: normalizedOffers, isOfferPersonalized: isOfferPersonalized ?? false, + developerBillingOption: developerBillingOption ?? undefined, subscriptionProductReplacementParams: subscriptionProductReplacementParams ?? undefined, })) as Purchase[]; diff --git a/libraries/expo-iap/src/modules/__tests__/android.test.ts b/libraries/expo-iap/src/modules/__tests__/android.test.ts index 10b4df0a4..0cb9e974d 100644 --- a/libraries/expo-iap/src/modules/__tests__/android.test.ts +++ b/libraries/expo-iap/src/modules/__tests__/android.test.ts @@ -21,8 +21,11 @@ import { acknowledgePurchaseAndroid, openRedeemOfferCodeAndroid, isBillingProgramAvailableAndroid, + getBillingChoiceInfoAndroid, launchExternalLinkAndroid, createBillingProgramReportingDetailsAndroid, + showBillingProgramInformationDialogAndroid, + showInAppMessagesAndroid, } from '../android'; /* eslint-enable import/first */ @@ -273,6 +276,44 @@ describe('Android Module Functions', () => { }); }); + describe('getBillingChoiceInfoAndroid', () => { + it('applies Billing Choice defaults', async () => { + const mockResult = { + playBillingChoiceImageUrl: 'https://play.google.com/image.png', + playBillingLoyaltyInfo: null, + }; + (ExpoIapModule.getBillingChoiceInfoAndroid as jest.Mock) + .mockResolvedValue(mockResult); + + const result = await getBillingChoiceInfoAndroid({}); + + expect(ExpoIapModule.getBillingChoiceInfoAndroid).toHaveBeenCalledWith({ + billingProgram: 'billing-choice', + playBillingChoiceImageLayout: 'rectangular-four-by-one', + userLocale: null, + }); + expect(result).toEqual(mockResult); + }); + + it('applies Billing Choice defaults when params are omitted', async () => { + const mockResult = { + playBillingChoiceImageUrl: 'https://play.google.com/image.png', + playBillingLoyaltyInfo: null, + }; + (ExpoIapModule.getBillingChoiceInfoAndroid as jest.Mock) + .mockResolvedValue(mockResult); + + const result = await (getBillingChoiceInfoAndroid as any)(); + + expect(ExpoIapModule.getBillingChoiceInfoAndroid).toHaveBeenCalledWith({ + billingProgram: 'billing-choice', + playBillingChoiceImageLayout: 'rectangular-four-by-one', + userLocale: null, + }); + expect(result).toEqual(mockResult); + }); + }); + describe('launchExternalLinkAndroid', () => { it('delegates to native module with valid params', async () => { ( @@ -314,6 +355,28 @@ describe('Android Module Functions', () => { }); }); + it('forwards the Billing Choice external transaction token', async () => { + ( + ExpoIapModule.launchExternalLinkAndroid as jest.Mock + ).mockResolvedValue(undefined); + + await launchExternalLinkAndroid({ + billingProgram: 'billing-choice', + externalTransactionToken: 'pre-generated-token', + launchMode: 'caller-will-launch-link', + linkType: 'link-to-digital-content-offer', + linkUri: 'https://example.com/checkout', + }); + + expect(ExpoIapModule.launchExternalLinkAndroid).toHaveBeenCalledWith({ + billingProgram: 'billing-choice', + externalTransactionToken: 'pre-generated-token', + launchMode: 'caller-will-launch-link', + linkType: 'link-to-digital-content-offer', + linkUri: 'https://example.com/checkout', + }); + }); + it('propagates errors from native module', async () => { const error = new Error('Activity not available'); ( @@ -381,7 +444,7 @@ describe('Android Module Functions', () => { expect( ExpoIapModule.createBillingProgramReportingDetailsAndroid, - ).toHaveBeenCalledWith('external-offer'); + ).toHaveBeenCalledWith('external-offer', null); expect(result).toEqual(mockResult); expect(result.externalTransactionToken).toBe('token-abc-123-xyz'); }); @@ -401,10 +464,30 @@ describe('Android Module Functions', () => { expect( ExpoIapModule.createBillingProgramReportingDetailsAndroid, - ).toHaveBeenCalledWith('external-content-link'); + ).toHaveBeenCalledWith('external-content-link', null); expect(result.billingProgram).toBe('external-content-link'); }); + it('passes developerBillingType for Billing Choice', async () => { + const mockResult = { + billingProgram: 'billing-choice', + externalTransactionToken: 'choice-token', + }; + ( + ExpoIapModule.createBillingProgramReportingDetailsAndroid as jest.Mock + ).mockResolvedValue(mockResult); + + const result = await createBillingProgramReportingDetailsAndroid( + 'billing-choice', + 'external-link', + ); + + expect( + ExpoIapModule.createBillingProgramReportingDetailsAndroid, + ).toHaveBeenCalledWith('billing-choice', 'external-link'); + expect(result).toEqual(mockResult); + }); + it('propagates errors from native module', async () => { const error = new Error('Failed to create reporting details'); ( @@ -416,5 +499,47 @@ describe('Android Module Functions', () => { ).rejects.toThrow('Failed to create reporting details'); }); }); + + describe('showBillingProgramInformationDialogAndroid', () => { + it('applies Billing Choice default program', async () => { + const mockResult = { + responseCode: 0, + debugMessage: null, + subResponseCode: 'no-applicable-sub-response-code', + }; + ( + ExpoIapModule.showBillingProgramInformationDialogAndroid as jest.Mock + ).mockResolvedValue(mockResult); + + const result = await showBillingProgramInformationDialogAndroid({ + externalTransactionToken: 'choice-token', + }); + + expect( + ExpoIapModule.showBillingProgramInformationDialogAndroid, + ).toHaveBeenCalledWith({ + billingProgram: 'billing-choice', + externalTransactionToken: 'choice-token', + }); + expect(result).toEqual(mockResult); + }); + }); + + describe('showInAppMessagesAndroid', () => { + it('delegates optional message categories', async () => { + const mockResult = {responseCode: 'no-action-needed'}; + (ExpoIapModule.showInAppMessagesAndroid as jest.Mock) + .mockResolvedValue(mockResult); + + const result = await showInAppMessagesAndroid({ + categories: ['transactional'], + }); + + expect(ExpoIapModule.showInAppMessagesAndroid).toHaveBeenCalledWith({ + categories: ['transactional'], + }); + expect(result).toEqual(mockResult); + }); + }); }); }); diff --git a/libraries/expo-iap/src/modules/android.ts b/libraries/expo-iap/src/modules/android.ts index d67f12f5d..e433c9a0b 100644 --- a/libraries/expo-iap/src/modules/android.ts +++ b/libraries/expo-iap/src/modules/android.ts @@ -6,8 +6,19 @@ import ExpoIapModule from '../ExpoIapModule'; // Types import type { + BillingChoiceInfoAndroid, + BillingProgramAndroid, + BillingProgramInformationDialogParamsAndroid, + BillingProgramReportingDetailsAndroid, + BillingResultAndroid, DeepLinkOptions, + DeveloperBillingTypeAndroid, + GetBillingChoiceInfoParamsAndroid, + InAppMessageParamsAndroid, + InAppMessageResultAndroid, + MutationCreateBillingProgramReportingDetailsAndroidArgs, MutationField, + QueryField, VerifyPurchaseResultAndroid, } from '../types'; @@ -296,9 +307,10 @@ export const createAlternativeBillingTokenAndroid: MutationField< /** * Check if a specific billing program is available for this user/device (Android only). - * Available in Google Play Billing Library 8.2.0+. + * Available in Google Play Billing Library 8.2.0+. Billing Choice availability + * details, including the configured renderer and external-link support, require 9.1.0+. * - * @param program - The billing program to check ('external-offer' or 'external-content-link') + * @param program - The billing program to check * @returns Promise resolving to availability result * * @example @@ -317,9 +329,32 @@ export const isBillingProgramAvailableAndroid: MutationField< return ExpoIapModule.isBillingProgramAvailableAndroid(program); }; +/** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + * Available in Google Play Billing Library 9.1.0+. + * + * @param params - Billing Choice info request parameters + * @returns Promise resolving to Play Billing Choice display information + * + * @see {@link https://openiap.dev/docs/apis/android/get-billing-choice-info-android} + */ +export const getBillingChoiceInfoAndroid: QueryField< + 'getBillingChoiceInfoAndroid' +> = async ( + params: GetBillingChoiceInfoParamsAndroid = {}, +): Promise => { + return ExpoIapModule.getBillingChoiceInfoAndroid({ + billingProgram: params.billingProgram ?? 'billing-choice', + playBillingChoiceImageLayout: + params.playBillingChoiceImageLayout ?? 'rectangular-four-by-one', + userLocale: params.userLocale ?? null, + }); +}; + /** * Launch an external link for the specified billing program (Android only). - * Available in Google Play Billing Library 8.2.0+. + * Available in Google Play Billing Library 8.2.0+; developer-rendered Billing + * Choice external-link flows require 9.1.0+ and `externalTransactionToken`. * * @param params - The external link parameters * @returns Promise resolving to true if the link was launched successfully @@ -327,7 +362,8 @@ export const isBillingProgramAvailableAndroid: MutationField< * @example * ```typescript * await launchExternalLinkAndroid({ - * billingProgram: 'external-offer', + * billingProgram: 'billing-choice', + * externalTransactionToken: 'pre-generated-token', * launchMode: 'launch-in-external-browser-or-app', * linkType: 'link-to-digital-content-offer', * linkUri: 'https://your-payment-site.com', @@ -361,8 +397,70 @@ export const launchExternalLinkAndroid: MutationField< * * @see {@link https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android} */ -export const createBillingProgramReportingDetailsAndroid: MutationField< +const createBillingProgramReportingDetailsAndroidField: MutationField< 'createBillingProgramReportingDetailsAndroid' -> = async (program) => { - return ExpoIapModule.createBillingProgramReportingDetailsAndroid(program); +> = async ( + args: MutationCreateBillingProgramReportingDetailsAndroidArgs, +): Promise => + ExpoIapModule.createBillingProgramReportingDetailsAndroid( + args.program, + args.developerBillingType ?? null, + ); + +export function createBillingProgramReportingDetailsAndroid( + args: MutationCreateBillingProgramReportingDetailsAndroidArgs, +): Promise; +export function createBillingProgramReportingDetailsAndroid( + program: BillingProgramAndroid, + developerBillingType?: DeveloperBillingTypeAndroid | null, +): Promise; +export function createBillingProgramReportingDetailsAndroid( + programOrArgs: + | BillingProgramAndroid + | MutationCreateBillingProgramReportingDetailsAndroidArgs, + developerBillingType?: DeveloperBillingTypeAndroid | null, +): Promise { + const args = + typeof programOrArgs === 'string' + ? {program: programOrArgs, developerBillingType} + : programOrArgs; + return createBillingProgramReportingDetailsAndroidField(args); +} + +/** + * Show Google's mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen. + * Available in Google Play Billing Library 9.1.0+. + * + * @param params - Dialog parameters with the external transaction token + * @returns Promise resolving to BillingResult + * + * @see {@link https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android} + */ +export const showBillingProgramInformationDialogAndroid: MutationField< + 'showBillingProgramInformationDialogAndroid' +> = async ( + params: BillingProgramInformationDialogParamsAndroid, +): Promise => { + return ExpoIapModule.showBillingProgramInformationDialogAndroid({ + billingProgram: params.billingProgram ?? 'billing-choice', + externalTransactionToken: params.externalTransactionToken, + }); +}; + +/** + * Show Play Billing in-app messages, such as transactional subscription updates. + * Available in Google Play Billing Library 4.1.0+. + * + * @param params - Optional in-app message categories + * @returns Promise resolving to in-app message result + * + * @see {@link https://openiap.dev/docs/apis/android/show-in-app-messages-android} + */ +export const showInAppMessagesAndroid: MutationField< + 'showInAppMessagesAndroid' +> = async ( + params?: InAppMessageParamsAndroid | null, +): Promise => { + return ExpoIapModule.showInAppMessagesAndroid(params ?? null); }; diff --git a/libraries/expo-iap/src/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts index d4670c8d5..ede068e63 100644 --- a/libraries/expo-iap/src/modules/ios.ts +++ b/libraries/expo-iap/src/modules/ios.ts @@ -8,8 +8,6 @@ import ExpoIapModule from '../ExpoIapModule'; import type { ExternalPurchaseCustomLinkNoticeResultIOS, ExternalPurchaseCustomLinkTokenResultIOS, - ExternalPurchaseCustomLinkTokenTypeIOS, - ExternalPurchaseCustomLinkNoticeTypeIOS, ExternalPurchaseLinkResultIOS, ExternalPurchaseNoticeResultIOS, MutationField, @@ -386,8 +384,10 @@ export const getPromotedProductIOS: QueryField< * * @see {@link https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios} */ -export const requestPurchaseOnPromotedProductIOS = - async (): Promise => { +export const requestPurchaseOnPromotedProductIOS: MutationField< + 'requestPurchaseOnPromotedProductIOS' +> = + async () => { const result = await ExpoIapModule.requestPurchaseOnPromotedProductIOS(); return result ?? true; }; @@ -470,8 +470,10 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< * * @see {@link https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios} */ -export const presentExternalPurchaseNoticeSheetIOS = - async (): Promise => { +export const presentExternalPurchaseNoticeSheetIOS: MutationField< + 'presentExternalPurchaseNoticeSheetIOS' +> = + async () => { const result = await ExpoIapModule.presentExternalPurchaseNoticeSheetIOS(); return result as ExternalPurchaseNoticeResultIOS; }; @@ -502,8 +504,10 @@ export const presentExternalPurchaseLinkIOS: MutationField< * * @see {@link https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios} */ -export const isEligibleForExternalPurchaseCustomLinkIOS = - async (): Promise => { +export const isEligibleForExternalPurchaseCustomLinkIOS: QueryField< + 'isEligibleForExternalPurchaseCustomLinkIOS' +> = + async () => { return !!(await ExpoIapModule.isEligibleForExternalPurchaseCustomLinkIOS()); }; @@ -518,9 +522,9 @@ export const isEligibleForExternalPurchaseCustomLinkIOS = * * @see {@link https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios} */ -export const getExternalPurchaseCustomLinkTokenIOS = async ( - tokenType: ExternalPurchaseCustomLinkTokenTypeIOS, -): Promise => { +export const getExternalPurchaseCustomLinkTokenIOS: QueryField< + 'getExternalPurchaseCustomLinkTokenIOS' +> = async (tokenType) => { if (!tokenType) { throw new Error( "getExternalPurchaseCustomLinkTokenIOS requires a tokenType ('acquisition' or 'services')", @@ -544,9 +548,9 @@ export const getExternalPurchaseCustomLinkTokenIOS = async ( * * @see {@link https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios} */ -export const showExternalPurchaseCustomLinkNoticeIOS = async ( - noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS, -): Promise => { +export const showExternalPurchaseCustomLinkNoticeIOS: MutationField< + 'showExternalPurchaseCustomLinkNoticeIOS' +> = async (noticeType) => { if (!noticeType) { throw new Error( "showExternalPurchaseCustomLinkNoticeIOS requires a noticeType ('browser')", diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 6d2d9b825..e36ce758d 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -26,6 +26,7 @@ export interface ActiveSubscription { * pending upgrades/downgrades, and auto-renewal preferences. */ renewalInfoIOS?: (RenewalInfoIOS | null); + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId: string; /** @@ -118,10 +119,35 @@ export interface AppTransaction { } /** - * Billing program types for external content links, external offers, and external payments (Android) - * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 + * Play Billing choice image layout (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ -export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'external-content-link' | 'external-offer' | 'external-payments'; +export type BillingChoiceImageLayoutAndroid = 'rectangular-four-by-one' | 'rectangular-three-by-one' | 'rectangular-two-by-two'; + +/** + * Display information for developer-rendered Billing Choice screens (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface BillingChoiceInfoAndroid { + /** URL for the Play Billing choice image matching the requested layout. */ + playBillingChoiceImageUrl: string; + /** Play Loyalty information for the user. */ + playBillingLoyaltyInfo?: (string | null); +} + +/** + * Choice screen renderer for Billing Choice availability (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export type BillingChoiceScreenTypeAndroid = 'unspecified' | 'developer-rendered' | 'google-rendered'; + +/** + * Billing program types for Google Play Billing Programs (Android) + * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, + * BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). + */ +export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'external-content-link' | 'external-offer' | 'external-payments' | 'billing-choice'; /** * Result of checking billing program availability (Android) @@ -130,8 +156,30 @@ export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'ext export interface BillingProgramAvailabilityResultAndroid { /** The billing program that was checked */ billingProgram: BillingProgramAndroid; + /** + * Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + choiceScreenType?: (BillingChoiceScreenTypeAndroid | null); /** Whether the billing program is available for the user */ isAvailable: boolean; + /** + * Whether external-link payment is available for Billing Choice. + * Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + isExternalLinkAvailable?: (boolean | null); +} + +/** + * Parameters for showing a billing program information dialog (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface BillingProgramInformationDialogParamsAndroid { + /** Billing program. Currently only BILLING_CHOICE is supported. */ + billingProgram?: BillingProgramAndroid; + /** External transaction token returned by the Billing Choice reporting-details flow. */ + externalTransactionToken: string; } /** @@ -180,19 +228,38 @@ export interface DeepLinkOptions { export type DeveloperBillingLaunchModeAndroid = 'unspecified' | 'launch-in-external-browser-or-app' | 'caller-will-launch-link'; /** - * Parameters for developer billing option in purchase flow (Android) - * Used with BillingFlowParams to enable external payments flow - * Available in Google Play Billing Library 8.3.0+ + * Parameters for a developer billing option in a purchase flow (Android). + * Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). + * Only billingProgram is required; link fields are used when the selected program + * links outside the app. */ export interface DeveloperBillingOptionParamsAndroid { - /** The billing program (should be EXTERNAL_PAYMENTS for external payments flow) */ + /** The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. */ billingProgram: BillingProgramAndroid; - /** The launch mode for the external payment link */ - launchMode: DeveloperBillingLaunchModeAndroid; - /** The URI where the external payment will be processed */ - linkUri: string; + /** + * A pre-generated external transaction token for a Billing Choice external-link + * flow. Omit it when Google Play should provide the token in the callback. + */ + externalTransactionToken?: (string | null); + /** + * The launch mode for the external payment link. + * Required only when the selected billing program links outside the app. + */ + launchMode?: (DeveloperBillingLaunchModeAndroid | null); + /** + * The URI where the external payment will be processed. + * Required only when the selected billing program links outside the app. + */ + linkUri?: (string | null); } +/** + * Developer-provided billing destination type for Billing Program reporting details (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export type DeveloperBillingTypeAndroid = 'developer-billing-type-unspecified' | 'in-app' | 'external-link'; + /** * Details provided when user selects developer billing option (Android) * Received via DeveloperProvidedBillingListener callback @@ -201,10 +268,31 @@ export interface DeveloperBillingOptionParamsAndroid { export interface DeveloperProvidedBillingDetailsAndroid { /** * External transaction token used to report transactions made through developer billing. - * This token must be used when reporting the external transaction to Google Play. - * Must be reported within 24 hours of the transaction. + * Nullable for flows such as external payments where no token is returned. */ - externalTransactionToken: string; + externalTransactionToken?: (string | null); + /** + * URI to launch for an external-link Billing Choice flow, when provided by + * Google Play. + */ + linkUri?: (string | null); + /** + * Original external transaction ID when replacing a subscription that was + * purchased through developer billing. + */ + originalExternalTransactionId?: (string | null); + /** Products selected for the developer billing flow. */ + products: DeveloperProvidedBillingProductAndroid[]; +} + +/** Product selected for developer-provided billing (Android 9.0+). */ +export interface DeveloperProvidedBillingProductAndroid { + /** Product identifier. */ + id: string; + /** Subscription offer token, when applicable. */ + offerToken?: (string | null); + /** Google Play product type (in-app or subscription). */ + type: ProductType; } /** @@ -507,6 +595,19 @@ export interface ExternalPurchaseNoticeResultIOS { export type FetchProductsResult = ProductOrSubscription[] | Product[] | ProductSubscription[] | null; +/** + * Parameters for fetching Billing Choice display information (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface GetBillingChoiceInfoParamsAndroid { + /** Billing program. Currently only BILLING_CHOICE is supported. */ + billingProgram?: BillingProgramAndroid; + /** Desired Play Billing choice image layout. */ + playBillingChoiceImageLayout?: BillingChoiceImageLayoutAndroid; + /** BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. */ + userLocale?: (string | null); +} + export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android' | 'subscription-billing-issue'; export type IapPlatform = 'ios' | 'android'; @@ -516,6 +617,42 @@ 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'; +/** + * High-level in-app message category (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export type InAppMessageCategoryAndroid = 'unknown-in-app-message-category-id' | 'transactional'; + +/** + * Parameters for showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export interface InAppMessageParamsAndroid { + /** In-app message categories to show. Defaults to transactional messages. */ + categories?: (InAppMessageCategoryAndroid[] | null); +} + +/** + * Response code from Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export type InAppMessageResponseCodeAndroid = 'no-action-needed' | 'subscription-status-updated'; + +/** + * Result from showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export interface InAppMessageResultAndroid { + /** Purchase token returned when a subscription status changed. */ + purchaseToken?: (string | null); + /** Response code for the in-app messaging flow. */ + responseCode: InAppMessageResponseCodeAndroid; +} + /** Connection initialization configuration */ export interface InitConnectionConfig { /** @@ -525,6 +662,16 @@ export interface InitConnectionConfig { * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. */ alternativeBillingModeAndroid?: (AlternativeBillingModeAndroid | null); + /** + * Billing Choice renderer configured in Play Console. Available in OpenIAP + * Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + * can emit the selection event. DEVELOPER_RENDERED omits that listener so the + * app can render its own choice screen and use the reporting/dialog/link APIs. + * Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + * Defaults to GOOGLE_RENDERED. + */ + billingChoiceScreenTypeAndroid?: (BillingChoiceScreenTypeAndroid | null); /** * Enable a specific billing program for Android (7.0+) * When set, enables the specified billing program for external transactions. @@ -532,6 +679,8 @@ export interface InitConnectionConfig { * - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) * - EXTERNAL_OFFER: External offers for digital content (8.2.0+) * - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + * - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) */ enableBillingProgramAndroid?: (BillingProgramAndroid | null); } @@ -559,12 +708,19 @@ export interface InstallmentPlanDetailsAndroid { /** * Parameters for launching an external link (Android) - * Used with launchExternalLink to initiate external offer or app install flows + * Used with launchExternalLink to initiate external offer, app install, or + * developer-rendered Billing Choice flows * Available in Google Play Billing Library 8.2.0+ */ export interface LaunchExternalLinkParamsAndroid { - /** The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) */ + /** The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) */ billingProgram: BillingProgramAndroid; + /** + * External transaction token for a developer-rendered Billing Choice external-link + * flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + */ + externalTransactionToken?: (string | null); /** The external link launch mode */ launchMode: ExternalLinkLaunchModeAndroid; /** The type of the external link */ @@ -597,7 +753,6 @@ export interface Mutation { beginRefundRequestIOS?: Promise<(string | null)>; /** * Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -617,7 +772,6 @@ export interface Mutation { * Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. * Must be called AFTER successful payment in your payment system. * Token must be reported to Google Play backend within 24 hours. - * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -626,8 +780,11 @@ export interface Mutation { /** * Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). * Replaces the deprecated createExternalOfferReportingDetailsAsync API. - * * Returns external transaction token needed for reporting external transactions. + * developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + * native Android defaults it to IN_APP. + * The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ @@ -655,7 +812,6 @@ export interface Mutation { /** * Check whether a billing program (e.g., External Payments) is available for the current user. * Replaces the deprecated isExternalOfferAvailableAsync API. - * * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. @@ -663,9 +819,11 @@ export interface Mutation { */ isBillingProgramAvailableAndroid: Promise; /** - * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + * including developer-rendered Billing Choice external-link flows. + * Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). * Replaces the deprecated showExternalOfferInformationDialog API. - * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -711,12 +869,19 @@ export interface Mutation { /** * Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. * Must be called BEFORE processing payment in your payment system. - * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ showAlternativeBillingDialogAndroid: Promise; + /** + * Show Google's mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + */ + showBillingProgramInformationDialogAndroid: Promise; /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. @@ -724,6 +889,15 @@ export interface Mutation { * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ showExternalPurchaseCustomLinkNoticeIOS: Promise; + /** + * Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + * Returns a response code and, when the subscription status changes, the related purchase token. + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + */ + showInAppMessagesAndroid: Promise; /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios @@ -766,7 +940,11 @@ export type MutationBeginRefundRequestIosArgs = string; export type MutationConsumePurchaseAndroidArgs = string; -export type MutationCreateBillingProgramReportingDetailsAndroidArgs = BillingProgramAndroid; +export interface MutationCreateBillingProgramReportingDetailsAndroidArgs { + developerBillingType?: (DeveloperBillingTypeAndroid | null); + program: BillingProgramAndroid; +} + export type MutationDeepLinkToSubscriptionsArgs = (DeepLinkOptions | null) | undefined; @@ -801,8 +979,12 @@ export type MutationRequestPurchaseArgs = }; +export type MutationShowBillingProgramInformationDialogAndroidArgs = BillingProgramInformationDialogParamsAndroid; + export type MutationShowExternalPurchaseCustomLinkNoticeIosArgs = ExternalPurchaseCustomLinkNoticeTypeIOS; +export type MutationShowInAppMessagesAndroidArgs = (InAppMessageParamsAndroid | null) | undefined; + export type MutationValidateReceiptArgs = VerifyPurchaseProps; export type MutationVerifyPurchaseArgs = VerifyPurchaseProps; @@ -1197,6 +1379,7 @@ export interface PurchaseAndroid extends PurchaseCommon { signatureAndroid?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId?: (string | null); } @@ -1221,6 +1404,7 @@ export interface PurchaseCommon { quantity: number; /** Store where purchase was made */ store: IapStore; + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; } @@ -1278,6 +1462,7 @@ export interface PurchaseIOS extends PurchaseCommon { store: IapStore; storefrontCountryCodeIOS?: (string | null); subscriptionGroupIdIOS?: (string | null); + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId: string; transactionReasonIOS?: (string | null); @@ -1359,6 +1544,13 @@ export interface Query { * See: https://openiap.dev/docs/apis/get-available-purchases */ getAvailablePurchases: Promise; + /** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client is not ready. + * See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + */ + getBillingChoiceInfoAndroid: Promise; /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. @@ -1447,6 +1639,8 @@ export type QueryGetActiveSubscriptionsArgs = (string[] | null) | undefined; export type QueryGetAvailablePurchasesArgs = (PurchaseOptions | null) | undefined; +export type QueryGetBillingChoiceInfoAndroidArgs = GetBillingChoiceInfoParamsAndroid; + export type QueryGetExternalPurchaseCustomLinkTokenIosArgs = ExternalPurchaseCustomLinkTokenTypeIOS; export type QueryGetTransactionJwsIosArgs = string; @@ -1546,9 +1740,9 @@ export interface RentalDetailsAndroid { export interface RequestPurchaseAndroidProps { /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ developerBillingOption?: (DeveloperBillingOptionParamsAndroid | null); /** @@ -1633,9 +1827,9 @@ export type RequestPurchaseResult = Purchase | Purchase[] | null; export interface RequestSubscriptionAndroidProps { /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ developerBillingOption?: (DeveloperBillingOptionParamsAndroid | null); /** @@ -1647,6 +1841,12 @@ export interface RequestSubscriptionAndroidProps { obfuscatedAccountId?: (string | null); /** Obfuscated profile ID */ obfuscatedProfileId?: (string | null); + /** + * Original external transaction ID for replacing a subscription that was + * purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + originalExternalTransactionId?: (string | null); /** Purchase token for upgrades/downgrades */ purchaseToken?: (string | null); /** @@ -1782,11 +1982,11 @@ export type SubResponseCodeAndroid = 'no-applicable-sub-response-code' | 'paymen export interface Subscription { /** - * Fires when a user selects developer billing in the External Payments flow (Android only) - * Triggered when the user chooses to pay via the developer's external payment option - * instead of Google Play Billing in the side-by-side choice dialog. - * Contains the externalTransactionToken needed to report the transaction. - * Available in Google Play Billing Library 8.3.0+ + * Fires when a user selects developer billing in an External Payments or + * Billing Choice flow (Android only). The payload can contain an external + * transaction token, link URI, original transaction ID, and selected products. + * Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ developerProvidedBillingAndroid: DeveloperProvidedBillingDetailsAndroid; /** Fires when the App Store surfaces a promoted product (iOS only) */ @@ -2235,6 +2435,7 @@ export type QueryArgsMap = { getAllTransactionsIOS: never; getAppTransactionIOS: never; getAvailablePurchases: QueryGetAvailablePurchasesArgs; + getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; getPendingTransactionsIOS: never; getPromotedProductIOS: never; @@ -2285,7 +2486,9 @@ export type MutationArgsMap = { requestPurchaseOnPromotedProductIOS: never; restorePurchases: never; showAlternativeBillingDialogAndroid: never; + showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidArgs; showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIosArgs; + showInAppMessagesAndroid: MutationShowInAppMessagesAndroidArgs; showManageSubscriptionsIOS: never; syncIOS: never; validateReceipt: MutationValidateReceiptArgs; diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts index d72df3df5..8998f8edc 100644 --- a/libraries/expo-iap/src/useIAP.ts +++ b/libraries/expo-iap/src/useIAP.ts @@ -10,6 +10,9 @@ import { purchaseErrorListener, purchaseUpdatedListener, promotedProductListenerIOS, + userChoiceBillingListenerAndroid, + developerProvidedBillingListenerAndroid, + subscriptionBillingIssueListener, getAvailablePurchases, finishTransaction as finishTransactionInternal, requestPurchase as requestPurchaseInternal, @@ -32,6 +35,12 @@ import { checkAlternativeBillingAvailabilityAndroid, showAlternativeBillingDialogAndroid, createAlternativeBillingTokenAndroid, + getBillingChoiceInfoAndroid, + isBillingProgramAvailableAndroid, + createBillingProgramReportingDetailsAndroid, + launchExternalLinkAndroid, + showBillingProgramInformationDialogAndroid, + showInAppMessagesAndroid, } from './modules/android'; // Types @@ -40,6 +49,11 @@ import type { ProductSubscription, ProductQueryType, ProductRequest, + AlternativeBillingModeAndroid, + BillingChoiceScreenTypeAndroid, + BillingProgramAndroid, + DeveloperProvidedBillingDetailsAndroid, + InitConnectionConfig, Purchase, MutationRequestPurchaseArgs, PurchaseInput, @@ -51,6 +65,9 @@ import type { ProductAndroid, ProductSubscriptionIOS, PurchaseOptions, + MutationField, + QueryField, + UserChoiceBillingDetails, } from './types'; import {ErrorCode} from './types'; import type {PurchaseError} from './utils/errorMapping'; @@ -125,6 +142,12 @@ type UseIap = { createAlternativeBillingTokenAndroid: ( sku?: string, ) => Promise; + getBillingChoiceInfoAndroid: QueryField<'getBillingChoiceInfoAndroid'>; + isBillingProgramAvailableAndroid: MutationField<'isBillingProgramAvailableAndroid'>; + createBillingProgramReportingDetailsAndroid: MutationField<'createBillingProgramReportingDetailsAndroid'>; + launchExternalLinkAndroid: MutationField<'launchExternalLinkAndroid'>; + showBillingProgramInformationDialogAndroid: MutationField<'showBillingProgramInformationDialogAndroid'>; + showInAppMessagesAndroid: MutationField<'showInAppMessagesAndroid'>; }; export interface UseIAPOptions { @@ -144,6 +167,16 @@ export interface UseIAPOptions { */ onError?: (error: Error) => void; onPromotedProductIOS?: (product: Product) => void; + onUserChoiceBillingAndroid?: (details: UserChoiceBillingDetails) => void; + /** + * Fires when the user selects developer-provided billing in an External + * Payments or Google-rendered Billing Choice flow. + */ + onDeveloperProvidedBillingAndroid?: ( + details: DeveloperProvidedBillingDetailsAndroid, + ) => void; + /** Fires when an active subscription enters a billing-issue state. */ + onSubscriptionBillingIssue?: (purchase: Purchase) => void; /** * Alternative billing mode for Android * If not specified, defaults to NONE (standard Google Play billing) @@ -151,19 +184,20 @@ export interface UseIAPOptions { * - 'user-choice' → 'user-choice-billing' * - 'alternative-only' → 'external-offer' */ - alternativeBillingModeAndroid?: 'none' | 'user-choice' | 'alternative-only'; + alternativeBillingModeAndroid?: AlternativeBillingModeAndroid; /** * Enable a specific billing program for Android (8.2.0+) * When set, enables the specified billing program for external transactions. * Use 'external-payments' for Developer Provided Billing (Japan only, 8.3.0+). * Use 'user-choice-billing' for User Choice Billing (7.0+). + * Use 'billing-choice' for Billing Choice (9.1.0+). */ - enableBillingProgramAndroid?: - | 'unspecified' - | 'external-content-link' - | 'external-offer' - | 'external-payments' - | 'user-choice-billing'; + enableBillingProgramAndroid?: BillingProgramAndroid; + /** + * Select who renders the Billing Choice screen (9.1.0+). Must match the + * choiceScreenType returned by isBillingProgramAvailableAndroid. + */ + billingChoiceScreenTypeAndroid?: BillingChoiceScreenTypeAndroid; } /** @@ -218,6 +252,9 @@ export function useIAP(options?: UseIAPOptions): UseIap { purchaseUpdate?: EventSubscription; purchaseError?: EventSubscription; promotedProductIOS?: EventSubscription; + userChoiceBillingAndroid?: EventSubscription; + developerProvidedBillingAndroid?: EventSubscription; + subscriptionBillingIssue?: EventSubscription; }>({}); const subscriptionsRefState = useRef([]); @@ -656,16 +693,29 @@ export function useIAP(options?: UseIAPOptions): UseIap { ); // Build config from options (prefer new enableBillingProgramAndroid over deprecated alternativeBillingModeAndroid) - const buildConnectionConfig = useCallback(() => { - return optionsRef.current?.enableBillingProgramAndroid || - optionsRef.current?.alternativeBillingModeAndroid - ? { - enableBillingProgramAndroid: - optionsRef.current.enableBillingProgramAndroid, - alternativeBillingModeAndroid: - optionsRef.current.alternativeBillingModeAndroid, - } - : undefined; + const buildConnectionConfig = useCallback((): + InitConnectionConfig | undefined => { + if (optionsRef.current?.enableBillingProgramAndroid) { + return { + enableBillingProgramAndroid: + optionsRef.current.enableBillingProgramAndroid, + ...(optionsRef.current.billingChoiceScreenTypeAndroid + ? { + billingChoiceScreenTypeAndroid: + optionsRef.current.billingChoiceScreenTypeAndroid, + } + : {}), + }; + } + + if (optionsRef.current?.alternativeBillingModeAndroid) { + return { + alternativeBillingModeAndroid: + optionsRef.current.alternativeBillingModeAndroid, + }; + } + + return undefined; }, []); const initIapWithSubscriptions = useCallback(async (): Promise => { @@ -712,6 +762,31 @@ export function useIAP(options?: UseIAPOptions): UseIap { }, ); + if ( + Platform.OS === 'android' && + optionsRef.current?.onUserChoiceBillingAndroid + ) { + subscriptionsRef.current.userChoiceBillingAndroid = + userChoiceBillingListenerAndroid((details) => { + optionsRef.current?.onUserChoiceBillingAndroid?.(details); + }); + } + + if ( + Platform.OS === 'android' && + optionsRef.current?.onDeveloperProvidedBillingAndroid + ) { + subscriptionsRef.current.developerProvidedBillingAndroid = + developerProvidedBillingListenerAndroid((details) => { + optionsRef.current?.onDeveloperProvidedBillingAndroid?.(details); + }); + } + + subscriptionsRef.current.subscriptionBillingIssue = + subscriptionBillingIssueListener((purchase) => { + optionsRef.current?.onSubscriptionBillingIssue?.(purchase); + }); + if (Platform.OS === 'ios') { // iOS promoted products listener subscriptionsRef.current.promotedProductIOS = promotedProductListenerIOS( @@ -823,6 +898,9 @@ export function useIAP(options?: UseIAPOptions): UseIap { currentSubscriptions.purchaseUpdate?.remove(); currentSubscriptions.purchaseError?.remove(); currentSubscriptions.promotedProductIOS?.remove(); + currentSubscriptions.userChoiceBillingAndroid?.remove(); + currentSubscriptions.developerProvidedBillingAndroid?.remove(); + currentSubscriptions.subscriptionBillingIssue?.remove(); endConnection(); setConnected(false); }; @@ -854,5 +932,11 @@ export function useIAP(options?: UseIAPOptions): UseIap { checkAlternativeBillingAvailabilityAndroid, showAlternativeBillingDialogAndroid, createAlternativeBillingTokenAndroid, + getBillingChoiceInfoAndroid, + isBillingProgramAvailableAndroid, + createBillingProgramReportingDetailsAndroid, + launchExternalLinkAndroid, + showBillingProgramInformationDialogAndroid, + showInAppMessagesAndroid, }; } diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index 60207f6e6..29f9d9bd3 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -21,6 +21,7 @@ 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_ATTEMPT_TIMEOUT_MS = 2_000; const NOTIFY_FULFILLMENT_MAX_ATTEMPTS = 15; const NOTIFY_FULFILLMENT_RETRY_DELAY_MS = 1_000; const PURCHASE_UPDATES_MAX_ATTEMPTS = 5; @@ -216,6 +217,26 @@ function delay(ms: number): Promise { }); } +function withTimeout( + operation: Promise, + timeoutMs: number, + timeoutError: Error, +): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => reject(timeoutError), timeoutMs); + operation.then( + (value) => { + clearTimeout(timeoutId); + resolve(value); + }, + (error) => { + clearTimeout(timeoutId); + reject(error); + }, + ); + }); +} + function isSuccess( operation: ResponseOperation, responseCode: unknown, @@ -803,12 +824,18 @@ export function createExpoIapVegaModule( attempt <= NOTIFY_FULFILLMENT_MAX_ATTEMPTS; attempt += 1 ) { - const response = await service.notifyFulfillment({ - fulfillmentResult: FULFILLMENT_RESULT_FULFILLED, - receiptId: purchaseToken, - }); + const response = await withTimeout( + service.notifyFulfillment({ + fulfillmentResult: FULFILLMENT_RESULT_FULFILLED, + receiptId: purchaseToken, + }), + NOTIFY_FULFILLMENT_ATTEMPT_TIMEOUT_MS, + createVegaError( + ErrorCode.ServiceTimeout, + 'Amazon Vega notifyFulfillment timed out.', + ), + ); if (isSuccess('notify-fulfillment', response?.responseCode)) return; - lastResponse = response; if (attempt < NOTIFY_FULFILLMENT_MAX_ATTEMPTS) { await delay(NOTIFY_FULFILLMENT_RETRY_DELAY_MS); @@ -838,7 +865,8 @@ export function createExpoIapVegaModule( const purchaseTimestamp = toTimestamp(receipt.purchaseDate); if ( options?.minPurchaseDateMs != null && - (purchaseTimestamp === 0 || purchaseTimestamp < options.minPurchaseDateMs) + (purchaseTimestamp === 0 || + purchaseTimestamp < options.minPurchaseDateMs) ) { continue; } @@ -919,7 +947,7 @@ export function createExpoIapVegaModule( try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' - ? (extractIapkitErrorMessage(parsed, depth + 1) ?? value) + ? extractIapkitErrorMessage(parsed, depth + 1) ?? value : value; } catch { return value; @@ -1266,11 +1294,11 @@ export function createExpoIapVegaModule( renewalInfoIOS: null, autoRenewingAndroid: purchase.platform === 'android' - ? (( + ? ( purchase as Purchase & { autoRenewingAndroid?: boolean | null; } - ).autoRenewingAndroid ?? null) + ).autoRenewingAndroid ?? null : null, basePlanIdAndroid: purchase.productId, currentPlanId: purchase.productId, @@ -1280,8 +1308,9 @@ export function createExpoIapVegaModule( async hasActiveSubscriptions( subscriptionIds?: string[] | null, ): Promise { - const subscriptions = - await vegaModule.getActiveSubscriptions(subscriptionIds); + const subscriptions = await vegaModule.getActiveSubscriptions( + subscriptionIds, + ); return subscriptions.length > 0; }, async acknowledgePurchaseAndroid(purchaseToken): Promise { diff --git a/libraries/flutter_inapp_purchase/README.md b/libraries/flutter_inapp_purchase/README.md index 24ea68893..bd6aee7ab 100644 --- a/libraries/flutter_inapp_purchase/README.md +++ b/libraries/flutter_inapp_purchase/README.md @@ -109,7 +109,7 @@ flutter_inapp_purchase conforms to the **[OpenIAP specification](https://openiap - **Shared specification** — Common types, error codes, and purchase flows across all platforms - **Generated type-safe bindings** — Swift, Kotlin, Dart, and GDScript from a single GraphQL schema -- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 8.x) +- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 9.1.0) - **Verification profiles** — Standardized receipt validation and purchase verification patterns Other libraries built on OpenIAP: [react-native-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap) · [expo-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap) · [kmp-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap) · [godot-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/godot-iap) 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 7d916b9a2..e94755437 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 @@ -8,16 +8,21 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import dev.hyo.openiap.AndroidSubscriptionOfferInput +import dev.hyo.openiap.BillingChoiceImageLayoutAndroid +import dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid import dev.hyo.openiap.BillingProgramAndroid import dev.hyo.openiap.DeepLinkOptions -import dev.hyo.openiap.DeveloperBillingLaunchModeAndroid import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid +import dev.hyo.openiap.DeveloperBillingTypeAndroid 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.GetBillingChoiceInfoParamsAndroid +import dev.hyo.openiap.InAppMessageCategoryAndroid +import dev.hyo.openiap.InAppMessageParamsAndroid import dev.hyo.openiap.InitConnectionConfig import dev.hyo.openiap.LaunchExternalLinkParamsAndroid import dev.hyo.openiap.OpenIapError @@ -141,6 +146,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act isOfferPersonalized: Boolean, subscriptionOffers: List, purchaseToken: String?, + originalExternalTransactionId: String? = null, replacementMode: Int?, offerToken: String? = null, developerBillingOption: DeveloperBillingOptionParamsAndroid? = null, @@ -161,6 +167,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act return when (type) { ProductQueryType.Subs -> { purchaseToken?.let { androidPayload[KEY_PURCHASE_TOKEN] = it } + originalExternalTransactionId?.let { + androidPayload[KEY_ORIGINAL_EXTERNAL_TRANSACTION_ID] = it + } replacementMode?.let { androidPayload[KEY_REPLACEMENT_MODE] = it } subscriptionProductReplacementParams?.let { androidPayload[KEY_SUBSCRIPTION_PRODUCT_REPLACEMENT_PARAMS] = it.toJson() @@ -304,7 +313,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act // Initialization / teardown when (call.method) { "initConnection" -> { - // Parse alternativeBillingModeAndroid and enableBillingProgramAndroid from arguments + // Parse Android billing configuration from arguments. val params = call.arguments as? Map<*, *> val configMap = mutableMapOf() params?.get("alternativeBillingModeAndroid")?.let { @@ -313,6 +322,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act params?.get("enableBillingProgramAndroid")?.let { configMap["enableBillingProgramAndroid"] = it } + params?.get("billingChoiceScreenTypeAndroid")?.let { + configMap["billingChoiceScreenTypeAndroid"] = it + } val newConfig = if (configMap.isEmpty()) { InitConnectionConfig() } else { @@ -490,35 +502,29 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val isOfferPersonalized = params["isOfferPersonalized"] as? Boolean ?: false val purchaseTokenAndroid = (params["purchaseToken"] ?: params["purchaseTokenAndroid"]) as? String + val originalExternalTransactionId = + params[KEY_ORIGINAL_EXTERNAL_TRANSACTION_ID] as? String val replacementModeAndroid = ((params["replacementMode"] ?: params["replacementModeAndroid"]) as? Number)?.toInt() // offerToken for one-time purchase discounts (Android 7.0+) val offerToken = params["offerToken"] as? String val useAlternativeBilling = params["useAlternativeBilling"] as? Boolean - // Parse developerBillingOption for External Payments (8.3.0+) + // Parse developerBillingOption for External Payments (8.3.0+) or Billing Choice (9.1.0+) val developerBillingOptionMap = params[KEY_DEVELOPER_BILLING_OPTION] as? Map<*, *> val developerBillingOption = developerBillingOptionMap?.let { optionMap -> try { - val billingProgram = BillingProgramAndroid.fromJson( - optionMap["billingProgram"] as? String ?: "unspecified" - ) - val launchMode = DeveloperBillingLaunchModeAndroid.fromJson( - optionMap["launchMode"] as? String ?: "unspecified" - ) - val linkUri = optionMap["linkUri"] as? String - if (!linkUri.isNullOrBlank()) { - DeveloperBillingOptionParamsAndroid( - billingProgram = billingProgram, - launchMode = launchMode, - linkUri = linkUri - ) - } else { - null - } + val json = optionMap.entries.mapNotNull { (key, value) -> + (key as? String)?.let { it to value } + }.toMap() + DeveloperBillingOptionParamsAndroid.fromJson(json) } catch (e: Exception) { - OpenIapLog.w(TAG, "Failed to parse developerBillingOption: ${e.message}") - null + safe.error( + OpenIapError.DeveloperError.CODE, + OpenIapError.DeveloperError.MESSAGE, + "Invalid developerBillingOption: ${e.message}" + ) + return } } @@ -535,11 +541,17 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act replacementMode = replacementMode ) } else { - null + throw IllegalArgumentException( + "oldProductId and replacementMode are required" + ) } } catch (e: Exception) { - OpenIapLog.w(TAG, "Failed to parse subscriptionProductReplacementParams: ${e.message}") - null + safe.error( + OpenIapError.DeveloperError.CODE, + OpenIapError.DeveloperError.MESSAGE, + "Invalid subscriptionProductReplacementParams: ${e.message}" + ) + return } } @@ -598,6 +610,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act isOfferPersonalized = isOfferPersonalized, subscriptionOffers = offers, purchaseToken = purchaseTokenAndroid, + originalExternalTransactionId = originalExternalTransactionId, replacementMode = replacementModeAndroid, offerToken = offerToken, developerBillingOption = developerBillingOption, @@ -607,6 +620,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act iap.requestPurchase(requestProps) // Success signaled by purchase-updated event safe.success(null) + } catch (e: OpenIapError) { + safe.error(e.code, e.message, e.debugMessage) } catch (e: Exception) { safe.error(OpenIapError.PurchaseFailed.CODE, OpenIapError.PurchaseFailed.MESSAGE, e.message) } @@ -783,7 +798,36 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val result = iap.isBillingProgramAvailable(program) val response = JSONObject().apply { put("billingProgram", result.billingProgram.toJson()) + put("choiceScreenType", result.choiceScreenType?.toJson()) put("isAvailable", result.isAvailable) + put("isExternalLinkAvailable", result.isExternalLinkAvailable) + } + safe.success(response.toString()) + } catch (e: Exception) { + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + } + } + } + "getBillingChoiceInfoAndroid" -> { + val programStr = call.argument("billingProgram") + val imageLayoutStr = call.argument("playBillingChoiceImageLayout") + val userLocale = call.argument("userLocale") + scope.launch { + try { + val iap = openIap + if (iap == null) { + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") + return@launch + } + val params = GetBillingChoiceInfoParamsAndroid( + billingProgram = BillingProgramAndroid.fromJson(programStr ?: "billing-choice"), + playBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.fromJson(imageLayoutStr ?: "rectangular-four-by-one"), + userLocale = userLocale + ) + val result = iap.getBillingChoiceInfo(params) + val response = JSONObject().apply { + put("playBillingChoiceImageUrl", result.playBillingChoiceImageUrl) + put("playBillingLoyaltyInfo", result.playBillingLoyaltyInfo) } safe.success(response.toString()) } catch (e: Exception) { @@ -793,6 +837,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } "createBillingProgramReportingDetailsAndroid" -> { val programStr = call.argument("program") + val developerBillingTypeStr = call.argument("developerBillingType") scope.launch { try { val iap = openIap @@ -801,7 +846,10 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act return@launch } val program = BillingProgramAndroid.fromJson(programStr ?: "unspecified") - val result = iap.createBillingProgramReportingDetails(program) + val developerBillingType = developerBillingTypeStr?.let { + DeveloperBillingTypeAndroid.fromJson(it) + } + val result = iap.createBillingProgramReportingDetails(program, developerBillingType) val response = JSONObject().apply { put("billingProgram", result.billingProgram.toJson()) put("externalTransactionToken", result.externalTransactionToken) @@ -812,11 +860,78 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } } + "showBillingProgramInformationDialogAndroid" -> { + val programStr = call.argument("billingProgram") + val token = call.argument("externalTransactionToken") + scope.launch { + try { + val iap = openIap + if (iap == null) { + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") + return@launch + } + val act = activity + if (act == null) { + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, "Activity not available") + return@launch + } + if (token.isNullOrBlank()) { + safe.error(OpenIapError.DeveloperError.CODE, "externalTransactionToken is required for showBillingProgramInformationDialogAndroid", null) + return@launch + } + val result = iap.showBillingProgramInformationDialog( + act, + BillingProgramInformationDialogParamsAndroid( + billingProgram = BillingProgramAndroid.fromJson(programStr ?: "billing-choice"), + externalTransactionToken = token + ) + ) + val response = JSONObject().apply { + put("responseCode", result.responseCode) + put("debugMessage", result.debugMessage) + put("subResponseCode", result.subResponseCode?.toJson()) + } + safe.success(response.toString()) + } catch (e: Exception) { + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + } + } + } + "showInAppMessagesAndroid" -> { + val categories = call.argument?>("categories") + scope.launch { + try { + val iap = openIap + if (iap == null) { + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") + return@launch + } + val act = activity + if (act == null) { + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, "Activity not available") + return@launch + } + val params = InAppMessageParamsAndroid( + categories = categories?.map { InAppMessageCategoryAndroid.fromJson(it) } + ) + val result = iap.showInAppMessages(act, params) + val response = JSONObject().apply { + put("responseCode", result.responseCode.toJson()) + put("purchaseToken", result.purchaseToken) + } + safe.success(response.toString()) + } catch (e: Exception) { + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + } + } + } "launchExternalLinkAndroid" -> { val programStr = call.argument("billingProgram") val launchModeStr = call.argument("launchMode") val linkTypeStr = call.argument("linkType") val linkUri: String? = call.argument("linkUri")?.takeIf { it.isNotBlank() } + val externalTransactionToken: String? = + call.argument("externalTransactionToken")?.takeIf { it.isNotBlank() } scope.launch { try { @@ -836,6 +951,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } val launchParams = LaunchExternalLinkParamsAndroid( billingProgram = BillingProgramAndroid.fromJson(programStr ?: "unspecified"), + externalTransactionToken = externalTransactionToken, launchMode = ExternalLinkLaunchModeAndroid.fromJson(launchModeStr ?: "unspecified"), linkType = ExternalLinkTypeAndroid.fromJson(linkTypeStr ?: "unspecified"), linkUri = linkUri @@ -983,6 +1099,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act iap.requestPurchase(requestProps) safe.success(null) + } catch (e: OpenIapError) { + safe.error(e.code, e.message, e.debugMessage) } catch (e: Exception) { safe.error(OpenIapError.PurchaseFailed.CODE, OpenIapError.PurchaseFailed.MESSAGE, e.message) } @@ -1358,6 +1476,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act private const val KEY_OBFUSCATED_ACCOUNT = "obfuscatedAccountId" private const val KEY_OBFUSCATED_PROFILE = "obfuscatedProfileId" private const val KEY_PURCHASE_TOKEN = "purchaseToken" + private const val KEY_ORIGINAL_EXTERNAL_TRANSACTION_ID = "originalExternalTransactionId" private const val KEY_REPLACEMENT_MODE = "replacementMode" private const val KEY_OFFER_TOKEN = "offerToken" private const val KEY_SUBSCRIPTION_OFFERS = "subscriptionOffers" diff --git a/libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml b/libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml index b1f423cd2..6f8a1988f 100644 --- a/libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml +++ b/libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml @@ -54,7 +54,7 @@ diff --git a/libraries/flutter_inapp_purchase/lib/builders.dart b/libraries/flutter_inapp_purchase/lib/builders.dart index af30414a2..a29cb481a 100644 --- a/libraries/flutter_inapp_purchase/lib/builders.dart +++ b/libraries/flutter_inapp_purchase/lib/builders.dart @@ -109,8 +109,11 @@ class RequestSubscriptionAndroidBuilder { String? obfuscatedAccountId; String? obfuscatedProfileId; String? purchaseToken; + String? originalExternalTransactionId; int? replacementMode; bool? isOfferPersonalized; + SubscriptionProductReplacementParamsAndroid? + subscriptionProductReplacementParams; DeveloperBillingOptionParamsAndroid? developerBillingOption; RequestSubscriptionAndroidBuilder(); @@ -123,8 +126,11 @@ class RequestSubscriptionAndroidBuilder { obfuscatedAccountId: obfuscatedAccountId, obfuscatedProfileId: obfuscatedProfileId, purchaseToken: purchaseToken, + originalExternalTransactionId: originalExternalTransactionId, replacementMode: replacementMode, isOfferPersonalized: isOfferPersonalized, + subscriptionProductReplacementParams: + subscriptionProductReplacementParams, developerBillingOption: developerBillingOption, ); } diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 4fdec26e4..1dfb332f2 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -291,8 +291,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { Stream get userChoiceBillingAndroid => _userChoiceBillingAndroidListener.stream; - /// Developer provided billing Android event stream (8.3.0+) - /// Fires when user selects developer-provided billing option in external payments flow. + /// Developer provided billing Android event stream (8.3.0+). + /// Fires for External Payments and Google-rendered Billing Choice selections. Stream get developerProvidedBillingAndroid => _developerProvidedBillingAndroidListener.stream; @@ -465,6 +465,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// See: https://openiap.dev/docs/apis/init-connection gentype.MutationInitConnectionHandler get initConnection => ({ gentype.AlternativeBillingModeAndroid? alternativeBillingModeAndroid, + gentype.BillingChoiceScreenTypeAndroid? billingChoiceScreenTypeAndroid, gentype.BillingProgramAndroid? enableBillingProgramAndroid, }) async { if (_isInitialized) { @@ -477,6 +478,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { // Build config map for alternative billing and billing program Map? config; if (alternativeBillingModeAndroid != null || + billingChoiceScreenTypeAndroid != null || enableBillingProgramAndroid != null) { config = {}; if (alternativeBillingModeAndroid != null) { @@ -487,6 +489,10 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { config['enableBillingProgramAndroid'] = enableBillingProgramAndroid.toJson(); } + if (billingChoiceScreenTypeAndroid != null) { + config['billingChoiceScreenTypeAndroid'] = + billingChoiceScreenTypeAndroid.toJson(); + } } await _channel.invokeMethod('initConnection', config); @@ -657,6 +663,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { final String? obfuscatedAccount; final String? obfuscatedProfile; final String? purchaseToken; + final String? originalExternalTransactionId; final int? replacementMode; final String? offerToken; final List? @@ -672,6 +679,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { obfuscatedAccount = androidProps.obfuscatedAccountId; obfuscatedProfile = androidProps.obfuscatedProfileId; purchaseToken = null; + originalExternalTransactionId = null; replacementMode = null; offerToken = androidProps.offerToken; subscriptionOffers = null; @@ -684,6 +692,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { obfuscatedAccount = androidProps.obfuscatedAccountId; obfuscatedProfile = androidProps.obfuscatedProfileId; purchaseToken = androidProps.purchaseToken; + originalExternalTransactionId = + androidProps.originalExternalTransactionId; replacementMode = androidProps.replacementMode; offerToken = null; // Subscriptions don't use offerToken subscriptionOffers = androidProps.subscriptionOffers; @@ -724,6 +734,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { payload['purchaseToken'] = purchaseToken; } + if (originalExternalTransactionId != null) { + payload['originalExternalTransactionId'] = + originalExternalTransactionId; + } + if (replacementMode != null) { payload['replacementMode'] = replacementMode; } @@ -744,7 +759,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { json['useAlternativeBilling'] as bool?; payload['useAlternativeBilling'] = useAlternativeBilling; - // Add developerBillingOption for External Payments (8.3.0+) + // Add developerBillingOption for External Payments (8.3.0+) or Billing Choice (9.1.0+) if (developerBillingOption != null) { payload['developerBillingOption'] = developerBillingOption.toJson(); @@ -2545,8 +2560,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android Future createBillingProgramReportingDetailsAndroid( - gentype.BillingProgramAndroid program, - ) async { + gentype.BillingProgramAndroid program, { + gentype.DeveloperBillingTypeAndroid? developerBillingType, + }) async { if (!_platform.isAndroid) { throw PurchaseError( code: gentype.ErrorCode.IapNotAvailable, @@ -2557,7 +2573,10 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { try { final result = await _channel.invokeMethod( 'createBillingProgramReportingDetailsAndroid', - {'program': program.toJson()}, + { + 'program': program.toJson(), + 'developerBillingType': developerBillingType?.toJson(), + }, ); if (result != null) { final json = jsonDecode(result) as Map; @@ -2573,6 +2592,101 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } } + /// Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens (Play Billing 9.1.0+). + /// + /// See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + Future getBillingChoiceInfoAndroid( + gentype.GetBillingChoiceInfoParamsAndroid params, + ) async { + if (!_platform.isAndroid) { + throw PurchaseError( + code: gentype.ErrorCode.IapNotAvailable, + message: 'getBillingChoiceInfoAndroid only available on Android', + ); + } + try { + final result = await _channel.invokeMethod( + 'getBillingChoiceInfoAndroid', + params.toJson(), + ); + if (result != null) { + final json = jsonDecode(result) as Map; + return gentype.BillingChoiceInfoAndroid.fromJson(json); + } + throw PurchaseError( + code: gentype.ErrorCode.Unknown, + message: 'Failed to get Billing Choice info', + ); + } catch (error) { + debugPrint('getBillingChoiceInfoAndroid error: $error'); + rethrow; + } + } + + /// Show Google's mandatory information dialog before a developer-rendered, + /// in-app Billing Choice screen (Play Billing 9.1.0+). + /// + /// See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + Future + showBillingProgramInformationDialogAndroid( + gentype.BillingProgramInformationDialogParamsAndroid params, + ) async { + if (!_platform.isAndroid) { + throw PurchaseError( + code: gentype.ErrorCode.IapNotAvailable, + message: + 'showBillingProgramInformationDialogAndroid only available on Android', + ); + } + try { + final result = await _channel.invokeMethod( + 'showBillingProgramInformationDialogAndroid', + params.toJson(), + ); + if (result != null) { + final json = jsonDecode(result) as Map; + return gentype.BillingResultAndroid.fromJson(json); + } + throw PurchaseError( + code: gentype.ErrorCode.Unknown, + message: 'Failed to show Billing Choice information dialog', + ); + } catch (error) { + debugPrint('showBillingProgramInformationDialogAndroid error: $error'); + rethrow; + } + } + + /// Show Play Billing in-app messages such as transactional subscription updates. + /// + /// See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + Future showInAppMessagesAndroid([ + gentype.InAppMessageParamsAndroid? params, + ]) async { + if (!_platform.isAndroid) { + throw PurchaseError( + code: gentype.ErrorCode.IapNotAvailable, + message: 'showInAppMessagesAndroid only available on Android', + ); + } + try { + final result = await _channel.invokeMethod( + 'showInAppMessagesAndroid', + params?.toJson(), + ); + if (result != null) { + final json = jsonDecode(result) as Map; + return gentype.InAppMessageResultAndroid.fromJson(json); + } + return const gentype.InAppMessageResultAndroid( + responseCode: gentype.InAppMessageResponseCodeAndroid.NoActionNeeded, + ); + } catch (error) { + debugPrint('showInAppMessagesAndroid error: $error'); + rethrow; + } + } + /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). /// /// See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -2589,6 +2703,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { final result = await _channel.invokeMethod('launchExternalLinkAndroid', { 'billingProgram': params.billingProgram.toJson(), + 'externalTransactionToken': params.externalTransactionToken, 'launchMode': params.launchMode.toJson(), 'linkType': params.linkType.toJson(), 'linkUri': params.linkUri, @@ -2818,6 +2933,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { getStorefront: getStorefront, getStorefrontIOS: getStorefrontIOS, getTransactionJwsIOS: getTransactionJwsIOS, + getBillingChoiceInfoAndroid: _getBillingChoiceInfoAndroidHandler, hasActiveSubscriptions: hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS: isEligibleForExternalPurchaseCustomLinkIOS, @@ -2834,23 +2950,70 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { required gentype.ExternalLinkLaunchModeAndroid launchMode, required gentype.ExternalLinkTypeAndroid linkType, required String linkUri, + String? externalTransactionToken, }) => launchExternalLinkAndroid( gentype.LaunchExternalLinkParamsAndroid( billingProgram: billingProgram, + externalTransactionToken: externalTransactionToken, launchMode: launchMode, linkType: linkType, linkUri: linkUri, ), ); + gentype.QueryGetBillingChoiceInfoAndroidHandler + get _getBillingChoiceInfoAndroidHandler => ({ + required gentype.BillingProgramAndroid billingProgram, + required gentype.BillingChoiceImageLayoutAndroid + playBillingChoiceImageLayout, + String? userLocale, + }) => + getBillingChoiceInfoAndroid( + gentype.GetBillingChoiceInfoParamsAndroid( + billingProgram: billingProgram, + playBillingChoiceImageLayout: playBillingChoiceImageLayout, + userLocale: userLocale, + ), + ); + + gentype.MutationCreateBillingProgramReportingDetailsAndroidHandler + get _createBillingProgramReportingDetailsAndroidHandler => ({ + required gentype.BillingProgramAndroid program, + gentype.DeveloperBillingTypeAndroid? developerBillingType, + }) => + createBillingProgramReportingDetailsAndroid( + program, + developerBillingType: developerBillingType, + ); + + gentype.MutationShowBillingProgramInformationDialogAndroidHandler + get _showBillingProgramInformationDialogAndroidHandler => ({ + required gentype.BillingProgramAndroid billingProgram, + required String externalTransactionToken, + }) => + showBillingProgramInformationDialogAndroid( + gentype.BillingProgramInformationDialogParamsAndroid( + billingProgram: billingProgram, + externalTransactionToken: externalTransactionToken, + ), + ); + + gentype.MutationShowInAppMessagesAndroidHandler + get _showInAppMessagesAndroidHandler => ({ + List? categories, + }) => + showInAppMessagesAndroid( + gentype.InAppMessageParamsAndroid(categories: categories), + ); + // ignore: deprecated_member_use_from_same_package gentype.MutationHandlers get mutationHandlers => gentype.MutationHandlers( acknowledgePurchaseAndroid: acknowledgePurchaseAndroid, beginRefundRequestIOS: beginRefundRequestIOS, consumePurchaseAndroid: consumePurchaseAndroid, createBillingProgramReportingDetailsAndroid: - createBillingProgramReportingDetailsAndroid, + _createBillingProgramReportingDetailsAndroidHandler, deepLinkToSubscriptions: deepLinkToSubscriptions, endConnection: endConnection, finishTransaction: finishTransaction, @@ -2863,6 +3026,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { // ignore: deprecated_member_use_from_same_package requestPurchaseOnPromotedProductIOS, restorePurchases: restorePurchases, + showBillingProgramInformationDialogAndroid: + _showBillingProgramInformationDialogAndroidHandler, + showInAppMessagesAndroid: _showInAppMessagesAndroidHandler, showManageSubscriptionsIOS: showManageSubscriptionsIOS, syncIOS: syncIOS, validateReceipt: validateReceipt, diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 1b9bf3fb5..4dce97a37 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -44,8 +44,68 @@ enum AlternativeBillingModeAndroid { String toJson() => value; } -/// Billing program types for external content links, external offers, and external payments (Android) -/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +/// Play Billing choice image layout (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum BillingChoiceImageLayoutAndroid { + /// Rectangular image with a 4:1 aspect ratio. + RectangularFourByOne('rectangular-four-by-one'), + /// Rectangular image with a 3:1 aspect ratio. + RectangularThreeByOne('rectangular-three-by-one'), + /// Rectangular image with a 2:2 aspect ratio. + RectangularTwoByTwo('rectangular-two-by-two'); + + const BillingChoiceImageLayoutAndroid(this.value); + final String value; + + factory BillingChoiceImageLayoutAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'rectangular-four-by-one': + return BillingChoiceImageLayoutAndroid.RectangularFourByOne; + case 'rectangular-three-by-one': + return BillingChoiceImageLayoutAndroid.RectangularThreeByOne; + case 'rectangular-two-by-two': + return BillingChoiceImageLayoutAndroid.RectangularTwoByTwo; + } + throw ArgumentError('Unknown BillingChoiceImageLayoutAndroid value: $value'); + } + + String toJson() => value; +} + +/// Choice screen renderer for Billing Choice availability (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum BillingChoiceScreenTypeAndroid { + /// Unspecified choice screen type. + Unspecified('unspecified'), + /// Choice screen is rendered by the developer app. + DeveloperRendered('developer-rendered'), + /// Choice screen is rendered by Google Play. + GoogleRendered('google-rendered'); + + const BillingChoiceScreenTypeAndroid(this.value); + final String value; + + factory BillingChoiceScreenTypeAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'unspecified': + return BillingChoiceScreenTypeAndroid.Unspecified; + case 'developer-rendered': + return BillingChoiceScreenTypeAndroid.DeveloperRendered; + case 'google-rendered': + return BillingChoiceScreenTypeAndroid.GoogleRendered; + } + throw ArgumentError('Unknown BillingChoiceScreenTypeAndroid value: $value'); + } + + String toJson() => value; +} + +/// Billing program types for Google Play Billing Programs (Android) +/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, +/// BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (requires Play Billing 9.1.0+). enum BillingProgramAndroid { /// Unspecified billing program. Do not use. Unspecified('unspecified'), @@ -65,7 +125,11 @@ enum BillingProgramAndroid { /// Allows presenting a side-by-side choice between Google Play Billing and developer's external payment option. /// Users can choose to complete the purchase on the developer's website. /// Available in Google Play Billing Library 8.3.0+ - ExternalPayments('external-payments'); + ExternalPayments('external-payments'), + /// Billing Choice program. + /// Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + BillingChoice('billing-choice'); const BillingProgramAndroid(this.value); final String value; @@ -83,6 +147,8 @@ enum BillingProgramAndroid { return BillingProgramAndroid.ExternalOffer; case 'external-payments': return BillingProgramAndroid.ExternalPayments; + case 'billing-choice': + return BillingProgramAndroid.BillingChoice; } throw ArgumentError('Unknown BillingProgramAndroid value: $value'); } @@ -122,6 +188,35 @@ enum DeveloperBillingLaunchModeAndroid { String toJson() => value; } +/// Developer-provided billing destination type for Billing Program reporting details (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum DeveloperBillingTypeAndroid { + /// Unspecified developer billing type. Do not use. + DeveloperBillingTypeUnspecified('developer-billing-type-unspecified'), + /// Developer-provided billing via native in-app experience. + InApp('in-app'), + /// Developer-provided billing via external link or embedded web browsing. + ExternalLink('external-link'); + + const DeveloperBillingTypeAndroid(this.value); + final String value; + + factory DeveloperBillingTypeAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'developer-billing-type-unspecified': + return DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified; + case 'in-app': + return DeveloperBillingTypeAndroid.InApp; + case 'external-link': + return DeveloperBillingTypeAndroid.ExternalLink; + } + throw ArgumentError('Unknown DeveloperBillingTypeAndroid value: $value'); + } + + String toJson() => value; +} + /// Discount offer type enumeration. /// Categorizes the type of discount or promotional offer. enum DiscountOfferType { @@ -423,8 +518,9 @@ enum IapEvent { PurchaseError('purchase-error'), PromotedProductIOS('promoted-product-ios'), UserChoiceBillingAndroid('user-choice-billing-android'), - /// Fired when user selects developer-provided billing option in external payments flow. - /// Available on Android with Google Play Billing Library 8.3.0+ + /// Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + /// developer billing selections on Android. Billing Choice is available in + /// OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). DeveloperProvidedBillingAndroid('developer-provided-billing-android'), /// Fired when an active subscription enters a billing-issue state that requires user attention. /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and @@ -560,6 +656,58 @@ enum IapStore { String toJson() => value; } +/// High-level in-app message category (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +enum InAppMessageCategoryAndroid { + /// Unknown in-app message category. + UnknownInAppMessageCategoryId('unknown-in-app-message-category-id'), + /// Transactional billing messages, such as payment issues or pending price-change confirmations. + Transactional('transactional'); + + const InAppMessageCategoryAndroid(this.value); + final String value; + + factory InAppMessageCategoryAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'unknown-in-app-message-category-id': + return InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId; + case 'transactional': + return InAppMessageCategoryAndroid.Transactional; + } + throw ArgumentError('Unknown InAppMessageCategoryAndroid value: $value'); + } + + String toJson() => value; +} + +/// Response code from Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +enum InAppMessageResponseCodeAndroid { + /// Flow finished and no developer action is needed. + NoActionNeeded('no-action-needed'), + /// Subscription status changed and the purchase token should be checked. + SubscriptionStatusUpdated('subscription-status-updated'); + + const InAppMessageResponseCodeAndroid(this.value); + final String value; + + factory InAppMessageResponseCodeAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'no-action-needed': + return InAppMessageResponseCodeAndroid.NoActionNeeded; + case 'subscription-status-updated': + return InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated; + } + throw ArgumentError('Unknown InAppMessageResponseCodeAndroid value: $value'); + } + + String toJson() => value; +} + /// Payment mode for subscription offers. /// Determines how the user pays during the offer period. enum PaymentMode { @@ -1229,6 +1377,7 @@ abstract class PurchaseCommon { int get quantity; /// Store where purchase was made IapStore get store; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. double get transactionDate; } @@ -1270,6 +1419,7 @@ class ActiveSubscription { /// Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, /// pending upgrades/downgrades, and auto-renewal preferences. final RenewalInfoIOS? renewalInfoIOS; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String transactionId; /// @deprecated iOS only - use daysUntilExpirationIOS instead. @@ -1527,23 +1677,63 @@ class AppTransaction { } } +/// Display information for developer-rendered Billing Choice screens (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class BillingChoiceInfoAndroid { + const BillingChoiceInfoAndroid({ + required this.playBillingChoiceImageUrl, + this.playBillingLoyaltyInfo, + }); + + /// URL for the Play Billing choice image matching the requested layout. + final String playBillingChoiceImageUrl; + /// Play Loyalty information for the user. + final String? playBillingLoyaltyInfo; + + factory BillingChoiceInfoAndroid.fromJson(Map json) { + return BillingChoiceInfoAndroid( + playBillingChoiceImageUrl: json['playBillingChoiceImageUrl'] as String, + playBillingLoyaltyInfo: json['playBillingLoyaltyInfo'] as String?, + ); + } + + Map toJson() { + return { + '__typename': 'BillingChoiceInfoAndroid', + 'playBillingChoiceImageUrl': playBillingChoiceImageUrl, + 'playBillingLoyaltyInfo': playBillingLoyaltyInfo, + }; + } +} + /// Result of checking billing program availability (Android) /// Available in Google Play Billing Library 8.2.0+ class BillingProgramAvailabilityResultAndroid { const BillingProgramAvailabilityResultAndroid({ required this.billingProgram, + this.choiceScreenType, required this.isAvailable, + this.isExternalLinkAvailable, }); /// The billing program that was checked final BillingProgramAndroid billingProgram; + /// Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + final BillingChoiceScreenTypeAndroid? choiceScreenType; /// Whether the billing program is available for the user final bool isAvailable; + /// Whether external-link payment is available for Billing Choice. + /// Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + final bool? isExternalLinkAvailable; factory BillingProgramAvailabilityResultAndroid.fromJson(Map json) { return BillingProgramAvailabilityResultAndroid( billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), + choiceScreenType: json['choiceScreenType'] != null ? BillingChoiceScreenTypeAndroid.fromJson(json['choiceScreenType'] as String) : null, isAvailable: json['isAvailable'] as bool, + isExternalLinkAvailable: json['isExternalLinkAvailable'] as bool?, ); } @@ -1551,7 +1741,9 @@ class BillingProgramAvailabilityResultAndroid { return { '__typename': 'BillingProgramAvailabilityResultAndroid', 'billingProgram': billingProgram.toJson(), + 'choiceScreenType': choiceScreenType?.toJson(), 'isAvailable': isAvailable, + 'isExternalLinkAvailable': isExternalLinkAvailable, }; } } @@ -1627,17 +1819,30 @@ class BillingResultAndroid { /// Available in Google Play Billing Library 8.3.0+ class DeveloperProvidedBillingDetailsAndroid { const DeveloperProvidedBillingDetailsAndroid({ - required this.externalTransactionToken, + this.externalTransactionToken, + this.linkUri, + this.originalExternalTransactionId, + required this.products, }); /// External transaction token used to report transactions made through developer billing. - /// This token must be used when reporting the external transaction to Google Play. - /// Must be reported within 24 hours of the transaction. - final String externalTransactionToken; + /// Nullable for flows such as external payments where no token is returned. + final String? externalTransactionToken; + /// URI to launch for an external-link Billing Choice flow, when provided by + /// Google Play. + final String? linkUri; + /// Original external transaction ID when replacing a subscription that was + /// purchased through developer billing. + final String? originalExternalTransactionId; + /// Products selected for the developer billing flow. + final List products; factory DeveloperProvidedBillingDetailsAndroid.fromJson(Map json) { return DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken: json['externalTransactionToken'] as String, + externalTransactionToken: json['externalTransactionToken'] as String?, + linkUri: json['linkUri'] as String?, + originalExternalTransactionId: json['originalExternalTransactionId'] as String?, + products: (json['products'] as List).map((e) => DeveloperProvidedBillingProductAndroid.fromJson(e as Map)).toList(), ); } @@ -1645,6 +1850,42 @@ class DeveloperProvidedBillingDetailsAndroid { return { '__typename': 'DeveloperProvidedBillingDetailsAndroid', 'externalTransactionToken': externalTransactionToken, + 'linkUri': linkUri, + 'originalExternalTransactionId': originalExternalTransactionId, + 'products': products.map((e) => e.toJson()).toList(), + }; + } +} + +/// Product selected for developer-provided billing (Android 9.0+). +class DeveloperProvidedBillingProductAndroid { + const DeveloperProvidedBillingProductAndroid({ + required this.id, + this.offerToken, + required this.type, + }); + + /// Product identifier. + final String id; + /// Subscription offer token, when applicable. + final String? offerToken; + /// Google Play product type (in-app or subscription). + final ProductType type; + + factory DeveloperProvidedBillingProductAndroid.fromJson(Map json) { + return DeveloperProvidedBillingProductAndroid( + id: json['id'] as String, + offerToken: json['offerToken'] as String?, + type: ProductType.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'DeveloperProvidedBillingProductAndroid', + 'id': id, + 'offerToken': offerToken, + 'type': type.toJson(), }; } } @@ -2141,6 +2382,36 @@ class FetchProductsResultSubscriptions extends FetchProductsResult { final List? value; } +/// Result from showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +class InAppMessageResultAndroid { + const InAppMessageResultAndroid({ + this.purchaseToken, + required this.responseCode, + }); + + /// Purchase token returned when a subscription status changed. + final String? purchaseToken; + /// Response code for the in-app messaging flow. + final InAppMessageResponseCodeAndroid responseCode; + + factory InAppMessageResultAndroid.fromJson(Map json) { + return InAppMessageResultAndroid( + purchaseToken: json['purchaseToken'] as String?, + responseCode: InAppMessageResponseCodeAndroid.fromJson(json['responseCode'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'InAppMessageResultAndroid', + 'purchaseToken': purchaseToken, + 'responseCode': responseCode.toJson(), + }; + } +} + /// Installment plan details for subscription offers (Android) /// Contains information about the installment plan commitment. /// Available in Google Play Billing Library 7.0+ @@ -2941,6 +3212,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { final String? signatureAndroid; /// Store where purchase was made final IapStore store; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String? transactionId; final bool? isAlternativeBilling; @@ -3134,6 +3406,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { final IapStore store; final String? storefrontCountryCodeIOS; final String? subscriptionGroupIdIOS; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String transactionId; final String? transactionReasonIOS; @@ -4292,6 +4565,34 @@ class AndroidSubscriptionOfferInput { } } +/// Parameters for showing a billing program information dialog (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class BillingProgramInformationDialogParamsAndroid { + const BillingProgramInformationDialogParamsAndroid({ + this.billingProgram = BillingProgramAndroid.BillingChoice, + required this.externalTransactionToken, + }); + + /// Billing program. Currently only BILLING_CHOICE is supported. + final BillingProgramAndroid billingProgram; + /// External transaction token returned by the Billing Choice reporting-details flow. + final String externalTransactionToken; + + factory BillingProgramInformationDialogParamsAndroid.fromJson(Map json) { + return BillingProgramInformationDialogParamsAndroid( + billingProgram: json['billingProgram'] != null ? BillingProgramAndroid.fromJson(json['billingProgram'] as String) : BillingProgramAndroid.BillingChoice, + externalTransactionToken: json['externalTransactionToken'] as String, + ); + } + + Map toJson() { + return { + 'billingProgram': billingProgram.toJson(), + 'externalTransactionToken': externalTransactionToken, + }; + } +} + class DeepLinkOptions { const DeepLinkOptions({ this.packageNameAndroid, @@ -4318,35 +4619,45 @@ class DeepLinkOptions { } } -/// Parameters for developer billing option in purchase flow (Android) -/// Used with BillingFlowParams to enable external payments flow -/// Available in Google Play Billing Library 8.3.0+ +/// Parameters for a developer billing option in a purchase flow (Android). +/// Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice +/// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). +/// Only billingProgram is required; link fields are used when the selected program +/// links outside the app. class DeveloperBillingOptionParamsAndroid { const DeveloperBillingOptionParamsAndroid({ required this.billingProgram, - required this.launchMode, - required this.linkUri, + this.externalTransactionToken, + this.launchMode, + this.linkUri, }); - /// The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + /// The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. final BillingProgramAndroid billingProgram; - /// The launch mode for the external payment link - final DeveloperBillingLaunchModeAndroid launchMode; - /// The URI where the external payment will be processed - final String linkUri; + /// A pre-generated external transaction token for a Billing Choice external-link + /// flow. Omit it when Google Play should provide the token in the callback. + final String? externalTransactionToken; + /// The launch mode for the external payment link. + /// Required only when the selected billing program links outside the app. + final DeveloperBillingLaunchModeAndroid? launchMode; + /// The URI where the external payment will be processed. + /// Required only when the selected billing program links outside the app. + final String? linkUri; factory DeveloperBillingOptionParamsAndroid.fromJson(Map json) { return DeveloperBillingOptionParamsAndroid( billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), - launchMode: DeveloperBillingLaunchModeAndroid.fromJson(json['launchMode'] as String), - linkUri: json['linkUri'] as String, + externalTransactionToken: json['externalTransactionToken'] as String?, + launchMode: json['launchMode'] != null ? DeveloperBillingLaunchModeAndroid.fromJson(json['launchMode'] as String) : null, + linkUri: json['linkUri'] as String?, ); } Map toJson() { return { 'billingProgram': billingProgram.toJson(), - 'launchMode': launchMode.toJson(), + 'externalTransactionToken': externalTransactionToken, + 'launchMode': launchMode?.toJson(), 'linkUri': linkUri, }; } @@ -4393,10 +4704,68 @@ class DiscountOfferInputIOS { } } +/// Parameters for fetching Billing Choice display information (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class GetBillingChoiceInfoParamsAndroid { + const GetBillingChoiceInfoParamsAndroid({ + this.billingProgram = BillingProgramAndroid.BillingChoice, + this.playBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + this.userLocale, + }); + + /// Billing program. Currently only BILLING_CHOICE is supported. + final BillingProgramAndroid billingProgram; + /// Desired Play Billing choice image layout. + final BillingChoiceImageLayoutAndroid playBillingChoiceImageLayout; + /// BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + final String? userLocale; + + factory GetBillingChoiceInfoParamsAndroid.fromJson(Map json) { + return GetBillingChoiceInfoParamsAndroid( + billingProgram: json['billingProgram'] != null ? BillingProgramAndroid.fromJson(json['billingProgram'] as String) : BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout: json['playBillingChoiceImageLayout'] != null ? BillingChoiceImageLayoutAndroid.fromJson(json['playBillingChoiceImageLayout'] as String) : BillingChoiceImageLayoutAndroid.RectangularFourByOne, + userLocale: json['userLocale'] as String?, + ); + } + + Map toJson() { + return { + 'billingProgram': billingProgram.toJson(), + 'playBillingChoiceImageLayout': playBillingChoiceImageLayout.toJson(), + 'userLocale': userLocale, + }; + } +} + +/// Parameters for showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +class InAppMessageParamsAndroid { + const InAppMessageParamsAndroid({ + this.categories = const [InAppMessageCategoryAndroid.Transactional], + }); + + /// In-app message categories to show. Defaults to transactional messages. + final List? categories; + + factory InAppMessageParamsAndroid.fromJson(Map json) { + return InAppMessageParamsAndroid( + categories: (json['categories'] as List?) == null ? const [InAppMessageCategoryAndroid.Transactional] : (json['categories'] as List?)!.map((e) => InAppMessageCategoryAndroid.fromJson(e as String)).toList(), + ); + } + + Map toJson() { + return { + 'categories': categories == null ? null : categories!.map((e) => e.toJson()).toList(), + }; + } +} + /// Connection initialization configuration class InitConnectionConfig { const InitConnectionConfig({ this.alternativeBillingModeAndroid, + this.billingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GoogleRendered, this.enableBillingProgramAndroid, }); @@ -4405,17 +4774,28 @@ class InitConnectionConfig { /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. final AlternativeBillingModeAndroid? alternativeBillingModeAndroid; + /// Billing Choice renderer configured in Play Console. Available in OpenIAP + /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + /// can emit the selection event. DEVELOPER_RENDERED omits that listener so the + /// app can render its own choice screen and use the reporting/dialog/link APIs. + /// Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + /// Defaults to GOOGLE_RENDERED. + final BillingChoiceScreenTypeAndroid? billingChoiceScreenTypeAndroid; /// Enable a specific billing program for Android (7.0+) /// When set, enables the specified billing program for external transactions. /// - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) /// - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) /// - EXTERNAL_OFFER: External offers for digital content (8.2.0+) /// - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + /// - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + /// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) final BillingProgramAndroid? enableBillingProgramAndroid; factory InitConnectionConfig.fromJson(Map json) { return InitConnectionConfig( alternativeBillingModeAndroid: json['alternativeBillingModeAndroid'] != null ? AlternativeBillingModeAndroid.fromJson(json['alternativeBillingModeAndroid'] as String) : null, + billingChoiceScreenTypeAndroid: json['billingChoiceScreenTypeAndroid'] != null ? BillingChoiceScreenTypeAndroid.fromJson(json['billingChoiceScreenTypeAndroid'] as String) : BillingChoiceScreenTypeAndroid.GoogleRendered, enableBillingProgramAndroid: json['enableBillingProgramAndroid'] != null ? BillingProgramAndroid.fromJson(json['enableBillingProgramAndroid'] as String) : null, ); } @@ -4423,24 +4803,31 @@ class InitConnectionConfig { Map toJson() { return { 'alternativeBillingModeAndroid': alternativeBillingModeAndroid?.toJson(), + 'billingChoiceScreenTypeAndroid': billingChoiceScreenTypeAndroid?.toJson(), 'enableBillingProgramAndroid': enableBillingProgramAndroid?.toJson(), }; } } /// Parameters for launching an external link (Android) -/// Used with launchExternalLink to initiate external offer or app install flows +/// Used with launchExternalLink to initiate external offer, app install, or +/// developer-rendered Billing Choice flows /// Available in Google Play Billing Library 8.2.0+ class LaunchExternalLinkParamsAndroid { const LaunchExternalLinkParamsAndroid({ required this.billingProgram, + this.externalTransactionToken, required this.launchMode, required this.linkType, required this.linkUri, }); - /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + /// The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) final BillingProgramAndroid billingProgram; + /// External transaction token for a developer-rendered Billing Choice external-link + /// flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + final String? externalTransactionToken; /// The external link launch mode final ExternalLinkLaunchModeAndroid launchMode; /// The type of the external link @@ -4451,6 +4838,7 @@ class LaunchExternalLinkParamsAndroid { factory LaunchExternalLinkParamsAndroid.fromJson(Map json) { return LaunchExternalLinkParamsAndroid( billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), + externalTransactionToken: json['externalTransactionToken'] as String?, launchMode: ExternalLinkLaunchModeAndroid.fromJson(json['launchMode'] as String), linkType: ExternalLinkTypeAndroid.fromJson(json['linkType'] as String), linkUri: json['linkUri'] as String, @@ -4460,6 +4848,7 @@ class LaunchExternalLinkParamsAndroid { Map toJson() { return { 'billingProgram': billingProgram.toJson(), + 'externalTransactionToken': externalTransactionToken, 'launchMode': launchMode.toJson(), 'linkType': linkType.toJson(), 'linkUri': linkUri, @@ -4470,7 +4859,7 @@ class LaunchExternalLinkParamsAndroid { class ProductRequest { const ProductRequest({ required this.skus, - this.type, + this.type = ProductQueryType.InApp, }); final List skus; @@ -4479,7 +4868,7 @@ class ProductRequest { factory ProductRequest.fromJson(Map json) { return ProductRequest( skus: (json['skus'] as List).map((e) => e as String).toList(), - type: json['type'] != null ? ProductQueryType.fromJson(json['type'] as String) : null, + type: json['type'] != null ? ProductQueryType.fromJson(json['type'] as String) : ProductQueryType.InApp, ); } @@ -4592,9 +4981,9 @@ class RequestPurchaseAndroidProps { required this.skus, }); - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). final DeveloperBillingOptionParamsAndroid? developerBillingOption; /// Personalized offer flag. /// When true, indicates the price was customized for this user. @@ -4792,6 +5181,7 @@ class RequestSubscriptionAndroidProps { this.isOfferPersonalized, this.obfuscatedAccountId, this.obfuscatedProfileId, + this.originalExternalTransactionId, this.purchaseToken, this.replacementMode, required this.skus, @@ -4799,9 +5189,9 @@ class RequestSubscriptionAndroidProps { this.subscriptionProductReplacementParams, }); - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). final DeveloperBillingOptionParamsAndroid? developerBillingOption; /// Personalized offer flag. /// When true, indicates the price was customized for this user. @@ -4810,6 +5200,10 @@ class RequestSubscriptionAndroidProps { final String? obfuscatedAccountId; /// Obfuscated profile ID final String? obfuscatedProfileId; + /// Original external transaction ID for replacing a subscription that was + /// purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). + final String? originalExternalTransactionId; /// Purchase token for upgrades/downgrades final String? purchaseToken; /// Replacement mode for subscription changes @@ -4829,6 +5223,7 @@ class RequestSubscriptionAndroidProps { isOfferPersonalized: json['isOfferPersonalized'] as bool?, obfuscatedAccountId: json['obfuscatedAccountId'] as String?, obfuscatedProfileId: json['obfuscatedProfileId'] as String?, + originalExternalTransactionId: json['originalExternalTransactionId'] as String?, purchaseToken: json['purchaseToken'] as String?, replacementMode: json['replacementMode'] as int?, skus: (json['skus'] as List).map((e) => e as String).toList(), @@ -4843,6 +5238,7 @@ class RequestSubscriptionAndroidProps { 'isOfferPersonalized': isOfferPersonalized, 'obfuscatedAccountId': obfuscatedAccountId, 'obfuscatedProfileId': obfuscatedProfileId, + 'originalExternalTransactionId': originalExternalTransactionId, 'purchaseToken': purchaseToken, 'replacementMode': replacementMode, 'skus': skus, @@ -5460,6 +5856,7 @@ sealed class Purchase implements PurchaseCommon { /// Store where purchase was made @override IapStore get store; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. @override double get transactionDate; @@ -5496,7 +5893,6 @@ abstract class MutationResolver { /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios Future beginRefundRequestIOS(String sku); /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -5510,18 +5906,23 @@ abstract class MutationResolver { /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. /// Token must be reported to Google Play backend within 24 hours. - /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android Future createAlternativeBillingTokenAndroid(); /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. - /// /// Returns external transaction token needed for reporting external transactions. + /// developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + /// native Android defaults it to IN_APP. + /// The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android - Future createBillingProgramReportingDetailsAndroid(BillingProgramAndroid program); + Future createBillingProgramReportingDetailsAndroid({ + required BillingProgramAndroid program, + DeveloperBillingTypeAndroid? developerBillingType, + }); /// Open the platform's subscription management UI. /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions Future deepLinkToSubscriptions({ @@ -5541,24 +5942,27 @@ abstract class MutationResolver { /// See: https://openiap.dev/docs/apis/init-connection Future initConnection({ AlternativeBillingModeAndroid? alternativeBillingModeAndroid, + BillingChoiceScreenTypeAndroid? billingChoiceScreenTypeAndroid, BillingProgramAndroid? enableBillingProgramAndroid, }); /// Check whether a billing program (e.g., External Payments) is available for the current user. /// Replaces the deprecated isExternalOfferAvailableAsync API. - /// /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android Future isBillingProgramAvailableAndroid(BillingProgramAndroid program); - /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + /// including developer-rendered Billing Choice external-link flows. + /// Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. - /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/launch-external-link-android Future launchExternalLinkAndroid({ required BillingProgramAndroid billingProgram, + String? externalTransactionToken, required ExternalLinkLaunchModeAndroid launchMode, required ExternalLinkTypeAndroid linkType, required String linkUri, @@ -5589,16 +5993,33 @@ abstract class MutationResolver { Future restorePurchases(); /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. - /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android Future showAlternativeBillingDialogAndroid(); + /// Show Google's mandatory information dialog before a developer-rendered, + /// in-app Billing Choice screen. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + Future showBillingProgramInformationDialogAndroid({ + required BillingProgramAndroid billingProgram, + required String externalTransactionToken, + }); /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios Future showExternalPurchaseCustomLinkNoticeIOS(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); + /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + /// (upstream API available since Play Billing 4.1.0). + /// Returns a response code and, when the subscription status changes, the related purchase token. + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + Future showInAppMessagesAndroid({ + List? categories, + }); /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios Future> showManageSubscriptionsIOS(); @@ -5667,6 +6088,15 @@ abstract class QueryResolver { bool? includeSuspendedAndroid, bool? onlyIncludeActiveItemsIOS, }); + /// Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client is not ready. + /// See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + Future getBillingChoiceInfoAndroid({ + required BillingProgramAndroid billingProgram, + required BillingChoiceImageLayoutAndroid playBillingChoiceImageLayout, + String? userLocale, + }); /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) @@ -5721,11 +6151,11 @@ abstract class QueryResolver { /// GraphQL root subscription operations. abstract class SubscriptionResolver { - /// Fires when a user selects developer billing in the External Payments flow (Android only) - /// Triggered when the user chooses to pay via the developer's external payment option - /// instead of Google Play Billing in the side-by-side choice dialog. - /// Contains the externalTransactionToken needed to report the transaction. - /// Available in Google Play Billing Library 8.3.0+ + /// Fires when a user selects developer billing in an External Payments or + /// Billing Choice flow (Android only). The payload can contain an external + /// transaction token, link URI, original transaction ID, and selected products. + /// Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). Future developerProvidedBillingAndroid(); /// Fires when the App Store surfaces a promoted product (iOS only) Future promotedProductIOS(); @@ -5765,7 +6195,10 @@ typedef MutationCheckAlternativeBillingAvailabilityAndroidHandler = Future typedef MutationClearTransactionIOSHandler = Future Function(); typedef MutationConsumePurchaseAndroidHandler = Future Function(String purchaseToken); typedef MutationCreateAlternativeBillingTokenAndroidHandler = Future Function(); -typedef MutationCreateBillingProgramReportingDetailsAndroidHandler = Future Function(BillingProgramAndroid program); +typedef MutationCreateBillingProgramReportingDetailsAndroidHandler = Future Function({ + required BillingProgramAndroid program, + DeveloperBillingTypeAndroid? developerBillingType, +}); typedef MutationDeepLinkToSubscriptionsHandler = Future Function({ String? packageNameAndroid, String? skuAndroid, @@ -5777,11 +6210,13 @@ typedef MutationFinishTransactionHandler = Future Function({ }); typedef MutationInitConnectionHandler = Future Function({ AlternativeBillingModeAndroid? alternativeBillingModeAndroid, + BillingChoiceScreenTypeAndroid? billingChoiceScreenTypeAndroid, BillingProgramAndroid? enableBillingProgramAndroid, }); typedef MutationIsBillingProgramAvailableAndroidHandler = Future Function(BillingProgramAndroid program); typedef MutationLaunchExternalLinkAndroidHandler = Future Function({ required BillingProgramAndroid billingProgram, + String? externalTransactionToken, required ExternalLinkLaunchModeAndroid launchMode, required ExternalLinkTypeAndroid linkType, required String linkUri, @@ -5793,7 +6228,14 @@ typedef MutationRequestPurchaseHandler = Future Function typedef MutationRequestPurchaseOnPromotedProductIOSHandler = Future Function(); typedef MutationRestorePurchasesHandler = Future Function(); typedef MutationShowAlternativeBillingDialogAndroidHandler = Future Function(); +typedef MutationShowBillingProgramInformationDialogAndroidHandler = Future Function({ + required BillingProgramAndroid billingProgram, + required String externalTransactionToken, +}); typedef MutationShowExternalPurchaseCustomLinkNoticeIOSHandler = Future Function(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); +typedef MutationShowInAppMessagesAndroidHandler = Future Function({ + List? categories, +}); typedef MutationShowManageSubscriptionsIOSHandler = Future> Function(); typedef MutationSyncIOSHandler = Future Function(); typedef MutationValidateReceiptHandler = Future Function({ @@ -5833,7 +6275,9 @@ class MutationHandlers { this.requestPurchaseOnPromotedProductIOS, this.restorePurchases, this.showAlternativeBillingDialogAndroid, + this.showBillingProgramInformationDialogAndroid, this.showExternalPurchaseCustomLinkNoticeIOS, + this.showInAppMessagesAndroid, this.showManageSubscriptionsIOS, this.syncIOS, this.validateReceipt, @@ -5861,7 +6305,9 @@ class MutationHandlers { final MutationRequestPurchaseOnPromotedProductIOSHandler? requestPurchaseOnPromotedProductIOS; final MutationRestorePurchasesHandler? restorePurchases; final MutationShowAlternativeBillingDialogAndroidHandler? showAlternativeBillingDialogAndroid; + final MutationShowBillingProgramInformationDialogAndroidHandler? showBillingProgramInformationDialogAndroid; final MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? showExternalPurchaseCustomLinkNoticeIOS; + final MutationShowInAppMessagesAndroidHandler? showInAppMessagesAndroid; final MutationShowManageSubscriptionsIOSHandler? showManageSubscriptionsIOS; final MutationSyncIOSHandler? syncIOS; final MutationValidateReceiptHandler? validateReceipt; @@ -5885,6 +6331,11 @@ typedef QueryGetAvailablePurchasesHandler = Future> Function({ bool? includeSuspendedAndroid, bool? onlyIncludeActiveItemsIOS, }); +typedef QueryGetBillingChoiceInfoAndroidHandler = Future Function({ + required BillingProgramAndroid billingProgram, + required BillingChoiceImageLayoutAndroid playBillingChoiceImageLayout, + String? userLocale, +}); typedef QueryGetExternalPurchaseCustomLinkTokenIOSHandler = Future Function(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); typedef QueryGetPendingTransactionsIOSHandler = Future> Function(); typedef QueryGetPromotedProductIOSHandler = Future Function(); @@ -5913,6 +6364,7 @@ class QueryHandlers { this.getAllTransactionsIOS, this.getAppTransactionIOS, this.getAvailablePurchases, + this.getBillingChoiceInfoAndroid, this.getExternalPurchaseCustomLinkTokenIOS, this.getPendingTransactionsIOS, this.getPromotedProductIOS, @@ -5936,6 +6388,7 @@ class QueryHandlers { final QueryGetAllTransactionsIOSHandler? getAllTransactionsIOS; final QueryGetAppTransactionIOSHandler? getAppTransactionIOS; final QueryGetAvailablePurchasesHandler? getAvailablePurchases; + final QueryGetBillingChoiceInfoAndroidHandler? getBillingChoiceInfoAndroid; final QueryGetExternalPurchaseCustomLinkTokenIOSHandler? getExternalPurchaseCustomLinkTokenIOS; final QueryGetPendingTransactionsIOSHandler? getPendingTransactionsIOS; final QueryGetPromotedProductIOSHandler? getPromotedProductIOS; diff --git a/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh b/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh index daaf1681b..c461847f0 100755 --- a/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh +++ b/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh @@ -14,9 +14,12 @@ cleanup() { trap cleanup EXIT package_copy="$tmp_root/flutter_inapp_purchase" +local_google_copy="$tmp_root/packages/google/openiap" consumer_app="$tmp_root/openiap_consumer_smoke" cp -R "$package_root" "$tmp_root/" +mkdir -p "$(dirname "$local_google_copy")" +cp -R "$repo_root/packages/google/openiap" "$local_google_copy" rm -rf \ "$package_copy/.build" \ "$package_copy/.dart_tool" \ @@ -26,13 +29,69 @@ rm -rf \ "$package_copy/example/.dart_tool" \ "$package_copy/example/android/.gradle" \ "$package_copy/example/android/build" \ - "$package_copy/example/build" + "$package_copy/example/build" \ + "$local_google_copy/.gradle" \ + "$local_google_copy/build" rm -f "$package_copy/openiap-versions.json" cp "$repo_root/openiap-versions.json" "$package_copy/openiap-versions.json" +cp "$repo_root/openiap-versions.json" "$tmp_root/openiap-versions.json" flutter create --platforms=android -t app --project-name openiap_consumer_smoke "$consumer_app" +google_root_build="$repo_root/packages/google/build.gradle.kts" +read_google_plugin_version() { + local plugin_id="$1" + local version + version="$(sed -nE "s/.*id\\(\"${plugin_id}\"\\) version \"([^\"]+)\".*/\\1/p" "$google_root_build" | head -n 1)" + if [ -z "$version" ]; then + echo "Failed to read Gradle plugin version for $plugin_id from $google_root_build" >&2 + return 1 + fi + printf '%s\n' "$version" +} + +replace_once() { + local file="$1" + local expression="$2" + local description="$3" + local before_file + before_file="$(mktemp "$tmp_root/perl-before.XXXXXX")" + cp "$file" "$before_file" + perl -0pi -e "$expression" "$file" + if cmp -s "$before_file" "$file"; then + echo "Failed to update $description in $file" >&2 + rm -f "$before_file" + return 1 + fi + rm -f "$before_file" +} + +android_library_version="$(read_google_plugin_version "com.android.library")" +compose_plugin_version="$(read_google_plugin_version "org.jetbrains.kotlin.plugin.compose")" +maven_publish_version="$(read_google_plugin_version "com.vanniktech.maven.publish")" + +if [ -f "$consumer_app/android/settings.gradle.kts" ]; then + settings_file="$consumer_app/android/settings.gradle.kts" + replace_once "$settings_file" "s/plugins \\{\\n/plugins {\\n id(\"com.android.library\") version \"$android_library_version\" apply false\\n id(\"org.jetbrains.kotlin.plugin.compose\") version \"$compose_plugin_version\" apply false\\n id(\"com.vanniktech.maven.publish\") version \"$maven_publish_version\" apply false\\n/" "Gradle plugin versions" + cat >> "$settings_file" <<'EOF' + +include(":openiap") +project(":openiap").projectDir = file("../../packages/google/openiap") +EOF +elif [ -f "$consumer_app/android/settings.gradle" ]; then + settings_file="$consumer_app/android/settings.gradle" + replace_once "$settings_file" "s/plugins \\{\\n/plugins {\\n id 'com.android.library' version '$android_library_version' apply false\\n id 'org.jetbrains.kotlin.plugin.compose' version '$compose_plugin_version' apply false\\n id 'com.vanniktech.maven.publish' version '$maven_publish_version' apply false\\n/" "Gradle plugin versions" + cat >> "$settings_file" <<'EOF' + +include ':openiap' +project(':openiap').projectDir = file('../../packages/google/openiap') +EOF +else + echo "Failed to locate generated Android settings.gradle(.kts) in $consumer_app/android" >&2 + exit 1 +fi + ( cd "$consumer_app" flutter pub add flutter_inapp_purchase --path "$package_copy" @@ -47,13 +106,28 @@ flutter create --platforms=android -t app --project-name openiap_consumer_smoke fi if [ -n "$android_sdk_root" ] && + [ -d "$android_sdk_root/ndk/28.2.13676358" ]; then + preferred_ndk_version="28.2.13676358" + elif [ -n "$android_sdk_root" ] && [ -d "$android_sdk_root/ndk/27.0.12077973" ]; then + preferred_ndk_version="27.0.12077973" + else + preferred_ndk_version="" + fi + + if [ -n "$preferred_ndk_version" ]; then if [ -f android/app/build.gradle.kts ]; then - perl -pi -e 's/ndkVersion\s*=\s*flutter\.ndkVersion/ndkVersion = "27.0.12077973"/' android/app/build.gradle.kts + replace_once android/app/build.gradle.kts "s/ndkVersion\\s*=\\s*flutter\\.ndkVersion/ndkVersion = \"$preferred_ndk_version\"/" "NDK version" elif [ -f android/app/build.gradle ]; then - perl -pi -e 's/ndkVersion\s*=?\s*flutter\.ndkVersion/ndkVersion = "27.0.12077973"/' android/app/build.gradle + replace_once android/app/build.gradle "s/ndkVersion\\s*=?\\s*flutter\\.ndkVersion/ndkVersion = \"$preferred_ndk_version\"/" "NDK version" fi fi + if [ -f android/app/build.gradle.kts ]; then + replace_once android/app/build.gradle.kts 's/defaultConfig \{\n/defaultConfig {\n missingDimensionStrategy("platform", "play")\n/' "OpenIAP platform flavor" + elif [ -f android/app/build.gradle ]; then + replace_once android/app/build.gradle 's/defaultConfig \{\n/defaultConfig {\n missingDimensionStrategy "platform", "play"\n/' "OpenIAP platform flavor" + fi + flutter build apk --debug ) diff --git a/libraries/flutter_inapp_purchase/test/builders_unit_test.dart b/libraries/flutter_inapp_purchase/test/builders_unit_test.dart index 912921d46..fbacd3221 100644 --- a/libraries/flutter_inapp_purchase/test/builders_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/builders_unit_test.dart @@ -94,15 +94,24 @@ void main() { final builder = RequestSubscriptionAndroidBuilder() ..skus = ['sub_sku'] ..purchaseToken = 'old_token' + ..originalExternalTransactionId = 'original-external-id' ..replacementMode = 1 ..obfuscatedAccountId = 'acc' - ..obfuscatedProfileId = 'prof'; + ..obfuscatedProfileId = 'prof' + ..subscriptionProductReplacementParams = + const SubscriptionProductReplacementParamsAndroid( + oldProductId: 'old_product', + replacementMode: SubscriptionReplacementModeAndroid.Deferred, + ); final props = builder.build(); expect(props.skus, ['sub_sku']); expect(props.purchaseToken, 'old_token'); + expect(props.originalExternalTransactionId, 'original-external-id'); expect(props.replacementMode, 1); + expect(props.subscriptionProductReplacementParams?.oldProductId, + 'old_product'); }); test('builds with empty subscriptionOffers returns null', () { 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 59a09eaaf..9599814bb 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 @@ -83,10 +83,174 @@ void main() { (MethodCall call) => call.method == 'createBillingProgramReportingDetailsAndroid', ); - expect(call.arguments, {'program': 'external-offer'}); + expect(call.arguments, { + 'program': 'external-offer', + 'developerBillingType': null, + }); }, ); + test( + 'createBillingProgramReportingDetailsAndroid passes developerBillingType', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + if (call.method == 'createBillingProgramReportingDetailsAndroid') { + return jsonEncode({ + 'billingProgram': 'billing-choice', + 'externalTransactionToken': 'choice-token-123', + }); + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + final result = await iap.createBillingProgramReportingDetailsAndroid( + types.BillingProgramAndroid.BillingChoice, + developerBillingType: types.DeveloperBillingTypeAndroid.ExternalLink, + ); + + expect( + result.billingProgram, types.BillingProgramAndroid.BillingChoice); + + final call = calls.singleWhere( + (MethodCall call) => + call.method == 'createBillingProgramReportingDetailsAndroid', + ); + expect(call.arguments, { + 'program': 'billing-choice', + 'developerBillingType': 'external-link', + }); + }, + ); + + test('getBillingChoiceInfoAndroid returns parsed display info', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + if (call.method == 'getBillingChoiceInfoAndroid') { + return jsonEncode({ + 'playBillingChoiceImageUrl': 'https://play.google.com/image.png', + 'playBillingLoyaltyInfo': 'Gold member', + }); + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + final result = await iap.getBillingChoiceInfoAndroid( + const types.GetBillingChoiceInfoParamsAndroid( + billingProgram: types.BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout: + types.BillingChoiceImageLayoutAndroid.RectangularFourByOne, + userLocale: 'en-US', + ), + ); + + expect(result.playBillingChoiceImageUrl, + 'https://play.google.com/image.png'); + expect(result.playBillingLoyaltyInfo, 'Gold member'); + + final call = calls.singleWhere( + (MethodCall call) => call.method == 'getBillingChoiceInfoAndroid', + ); + expect(call.arguments, { + 'billingProgram': 'billing-choice', + 'playBillingChoiceImageLayout': 'rectangular-four-by-one', + 'userLocale': 'en-US', + }); + }); + + test('showBillingProgramInformationDialogAndroid returns billing result', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + if (call.method == 'showBillingProgramInformationDialogAndroid') { + return jsonEncode({ + 'responseCode': 0, + 'debugMessage': null, + 'subResponseCode': 'no-applicable-sub-response-code', + }); + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + final result = await iap.showBillingProgramInformationDialogAndroid( + const types.BillingProgramInformationDialogParamsAndroid( + billingProgram: types.BillingProgramAndroid.BillingChoice, + externalTransactionToken: 'choice-token', + ), + ); + + expect(result.responseCode, 0); + expect( + result.subResponseCode, + types.SubResponseCodeAndroid.NoApplicableSubResponseCode, + ); + final call = calls.singleWhere( + (MethodCall call) => + call.method == 'showBillingProgramInformationDialogAndroid', + ); + expect(call.arguments, { + 'billingProgram': 'billing-choice', + 'externalTransactionToken': 'choice-token', + }); + }); + + test('showInAppMessagesAndroid returns response code', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + if (call.method == 'showInAppMessagesAndroid') { + return jsonEncode({ + 'responseCode': 'subscription-status-updated', + 'purchaseToken': 'purchase-token', + }); + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + final result = await iap.showInAppMessagesAndroid( + const types.InAppMessageParamsAndroid( + categories: [ + types.InAppMessageCategoryAndroid.Transactional, + ], + ), + ); + + expect( + result.responseCode, + types.InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated, + ); + expect(result.purchaseToken, 'purchase-token'); + final call = calls.singleWhere( + (MethodCall call) => call.method == 'showInAppMessagesAndroid', + ); + expect(call.arguments, { + 'categories': ['transactional'], + }); + }); + test( 'initConnection passes enableBillingProgramAndroid to native channel', () async { @@ -105,8 +269,10 @@ void main() { ); await iap.initConnection( + billingChoiceScreenTypeAndroid: + types.BillingChoiceScreenTypeAndroid.DeveloperRendered, enableBillingProgramAndroid: - types.BillingProgramAndroid.ExternalPayments, + types.BillingProgramAndroid.BillingChoice, ); final call = calls.singleWhere( @@ -114,7 +280,8 @@ void main() { ); final args = call.arguments as Map?; expect(args, isNotNull); - expect(args!['enableBillingProgramAndroid'], 'external-payments'); + expect(args!['billingChoiceScreenTypeAndroid'], 'developer-rendered'); + expect(args['enableBillingProgramAndroid'], 'billing-choice'); }, ); @@ -281,7 +448,8 @@ void main() { ); const params = types.LaunchExternalLinkParamsAndroid( - billingProgram: types.BillingProgramAndroid.ExternalOffer, + billingProgram: types.BillingProgramAndroid.BillingChoice, + externalTransactionToken: 'external-token', launchMode: types.ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, linkType: types.ExternalLinkTypeAndroid.LinkToDigitalContentOffer, @@ -298,6 +466,7 @@ void main() { methodCall.arguments as Map, ); expect(payload, params.toJson()); + expect(payload['externalTransactionToken'], 'external-token'); }); }); @@ -846,7 +1015,7 @@ void main() { ); test( - 'sends developerBillingOption for External Payments on Android', + 'sends minimal in-app Billing Choice option on Android', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -872,10 +1041,7 @@ void main() { google: types.RequestPurchaseAndroidProps( skus: ['product.premium'], developerBillingOption: types.DeveloperBillingOptionParamsAndroid( - billingProgram: types.BillingProgramAndroid.ExternalPayments, - launchMode: types.DeveloperBillingLaunchModeAndroid - .LaunchInExternalBrowserOrApp, - linkUri: 'https://example.com/checkout', + billingProgram: types.BillingProgramAndroid.BillingChoice, ), ), useAlternativeBilling: null, @@ -897,20 +1063,14 @@ void main() { final developerBillingOption = Map.from( payload['developerBillingOption'] as Map, ); - expect(developerBillingOption['billingProgram'], 'external-payments'); - expect( - developerBillingOption['launchMode'], - 'launch-in-external-browser-or-app', - ); - expect( - developerBillingOption['linkUri'], - 'https://example.com/checkout', - ); + expect(developerBillingOption['billingProgram'], 'billing-choice'); + expect(developerBillingOption['launchMode'], isNull); + expect(developerBillingOption['linkUri'], isNull); }, ); test( - 'sends developerBillingOption for External Payments on subscription Android', + 'sends Billing Choice fields on Android subscription', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -935,8 +1095,10 @@ void main() { apple: null, google: types.RequestSubscriptionAndroidProps( skus: ['sub.premium.monthly'], + originalExternalTransactionId: 'original-external-id', developerBillingOption: types.DeveloperBillingOptionParamsAndroid( - billingProgram: types.BillingProgramAndroid.ExternalPayments, + billingProgram: types.BillingProgramAndroid.BillingChoice, + externalTransactionToken: 'pre-generated-token', launchMode: types.DeveloperBillingLaunchModeAndroid.CallerWillLaunchLink, linkUri: 'https://example.com/subscribe', @@ -963,12 +1125,18 @@ void main() { expect(payload['type'], 'subs'); expect(payload['skus'], ['sub.premium.monthly']); expect(payload['useAlternativeBilling'], isTrue); + expect( + payload['originalExternalTransactionId'], 'original-external-id'); expect(payload.containsKey('developerBillingOption'), isTrue); final developerBillingOption = Map.from( payload['developerBillingOption'] as Map, ); - expect(developerBillingOption['billingProgram'], 'external-payments'); + expect(developerBillingOption['billingProgram'], 'billing-choice'); + expect( + developerBillingOption['externalTransactionToken'], + 'pre-generated-token', + ); expect(developerBillingOption['launchMode'], 'caller-will-launch-link'); expect( developerBillingOption['linkUri'], @@ -1684,7 +1852,16 @@ void main() { await iap.initConnection(); final payload = { - 'externalTransactionToken': 'ext-token-abc123', + 'externalTransactionToken': null, + 'linkUri': 'https://example.com/checkout', + 'originalExternalTransactionId': 'original-external-id', + 'products': >[ + { + 'id': 'premium_monthly', + 'type': 'subs', + 'offerToken': 'offer-token', + }, + ], }; await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -1700,7 +1877,11 @@ void main() { ); final details = await developerBillingFuture; - expect(details.externalTransactionToken, 'ext-token-abc123'); + expect(details.externalTransactionToken, isNull); + expect(details.linkUri, 'https://example.com/checkout'); + expect(details.originalExternalTransactionId, 'original-external-id'); + expect(details.products.single.id, 'premium_monthly'); + expect(details.products.single.offerToken, 'offer-token'); }, ); }); diff --git a/libraries/godot-iap/CLAUDE.md b/libraries/godot-iap/CLAUDE.md index eb3901e53..0fd90beb9 100644 --- a/libraries/godot-iap/CLAUDE.md +++ b/libraries/godot-iap/CLAUDE.md @@ -5,6 +5,7 @@ This document outlines conventions and guidelines for the godot-iap project. ## Project Overview godot-iap is a Godot 4.x plugin for in-app purchases following the [OpenIAP](https://openiap.dev) specification. +The SwiftGodot runtime requires iOS 17 or later; Android supports API 24 or later. ### Architecture diff --git a/libraries/godot-iap/Example/export_presets.cfg b/libraries/godot-iap/Example/export_presets.cfg index ac708b0ce..4ca9771c7 100644 --- a/libraries/godot-iap/Example/export_presets.cfg +++ b/libraries/godot-iap/Example/export_presets.cfg @@ -258,7 +258,7 @@ icons/app_store_1024x1024="res://icons/openiap-1024.png" icons/app_store_1024x1024_dark="" icons/app_store_1024x1024_tinted="" application/targeted_device_family=2 -application/min_ios_version="15.0" +application/min_ios_version="17.0" storyboard/image_scale_mode=0 storyboard/custom_image@2x="" storyboard/custom_image@3x="" diff --git a/libraries/godot-iap/Example/project.godot b/libraries/godot-iap/Example/project.godot index 8cb12caa8..b08a0d1f9 100644 --- a/libraries/godot-iap/Example/project.godot +++ b/libraries/godot-iap/Example/project.godot @@ -16,7 +16,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true config/name="GodotIap Example" run/main_scene="res://main.tscn" -config/features=PackedStringArray("4.6", "GL Compatibility") +config/features=PackedStringArray("4.5", "GL Compatibility") config/icon="res://openiap-logo.png" [autoload] diff --git a/libraries/godot-iap/Example/tests/test_godot_iap.gd b/libraries/godot-iap/Example/tests/test_godot_iap.gd index 106fc952d..6794274f3 100644 --- a/libraries/godot-iap/Example/tests/test_godot_iap.gd +++ b/libraries/godot-iap/Example/tests/test_godot_iap.gd @@ -10,6 +10,20 @@ var _tests_failed := 0 var GodotIapPlugin: Node = null +class FakeAndroidPlugin: + extends RefCounted + var last_config: Dictionary = {} + var last_purchase: Dictionary = {} + + func initConnectionWithConfig(config_json: String) -> bool: + last_config = JSON.parse_string(config_json) + return true + + func requestPurchaseJson(params_json: String) -> String: + last_purchase = JSON.parse_string(params_json) + return JSON.stringify({"success": true, "pending": true}) + + func _init() -> void: _run_suite.call_deferred() @@ -46,6 +60,7 @@ func _run_all_tests() -> void: test_product_variant_mapping() # Purchase tests + test_billing_choice_android_payloads() test_get_available_purchases_mock() test_restore_purchases_mock() @@ -109,6 +124,54 @@ func test_init_connection_mock() -> void: _assert_false(result, "init_connection should return false without a native plugin") +func test_billing_choice_android_payloads() -> void: + var fake = FakeAndroidPlugin.new() + GodotIapPlugin._native_plugin = fake + GodotIapPlugin._platform = "Android" + + var config = Types.InitConnectionConfig.new() + config.enable_billing_program_android = Types.BillingProgramAndroid.BILLING_CHOICE + config.billing_choice_screen_type_android = Types.BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED + _assert_true(GodotIapPlugin.init_connection(config), "Billing Choice config should reach Android plugin") + _assert_equal(fake.last_config.get("enableBillingProgramAndroid"), "billing-choice", "Billing Choice program should be preserved") + _assert_equal(fake.last_config.get("billingChoiceScreenTypeAndroid"), "developer-rendered", "Billing Choice renderer should be preserved") + + var subscription = Types.RequestSubscriptionAndroidProps.new() + var skus: Array[String] = ["monthly_subscription"] + subscription.skus = skus + subscription.original_external_transaction_id = "original-external-id" + var option = Types.DeveloperBillingOptionParamsAndroid.new() + option.billing_program = Types.BillingProgramAndroid.BILLING_CHOICE + subscription.developer_billing_option = option + GodotIapPlugin._request_purchase_raw({ + "type": "subs", + "requestSubscription": {"google": subscription.to_dict()}, + }) + _assert_equal(fake.last_purchase.get("originalExternalTransactionId"), "original-external-id", "Original external transaction ID should reach Android plugin") + _assert_equal(fake.last_purchase.get("developerBillingOption", {}).get("billingProgram"), "billing-choice", "Developer billing option should reach Android plugin") + + var replacement_params = Types.SubscriptionProductReplacementParamsAndroid.new() + replacement_params.old_product_id = "legacy-monthly" + replacement_params.replacement_mode = Types.SubscriptionReplacementModeAndroid.CHARGE_PRORATED_PRICE + var raw_option = Types.DeveloperBillingOptionParamsAndroid.new() + raw_option.billing_program = Types.BillingProgramAndroid.BILLING_CHOICE + GodotIapPlugin._request_purchase_raw({ + "type": "subs", + "requestSubscription": {"google": { + "skus": ["monthly_subscription"], + "subscriptionProductReplacementParams": replacement_params, + "developerBillingOption": raw_option, + }}, + }) + _assert_equal(fake.last_purchase.get("subscriptionProductReplacementParams", {}).get("oldProductId"), "legacy-monthly", "Replacement params objects should serialize to dictionaries") + _assert_equal(fake.last_purchase.get("subscriptionProductReplacementParams", {}).get("replacementMode"), "charge-prorated-price", "Replacement mode should preserve its serialized value") + _assert_equal(fake.last_purchase.get("developerBillingOption", {}).get("billingProgram"), "billing-choice", "Developer billing option objects should serialize to dictionaries") + + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + GodotIapPlugin._is_connected = false + + func test_end_connection_mock() -> void: var result = GodotIapPlugin.end_connection() _assert_true(result, "end_connection should return true in mock mode") @@ -204,11 +267,11 @@ func test_finish_transaction_mock() -> void: func test_ios_methods_mock() -> void: # sync_ios var sync_result = GodotIapPlugin.sync_ios() - _assert_true(sync_result is Types.VoidResult, "sync_ios should return VoidResult") + _assert_true(sync_result is bool, "sync_ios should return bool") # clear_transaction_ios var clear_result = GodotIapPlugin.clear_transaction_ios() - _assert_true(clear_result is Types.VoidResult, "clear_transaction_ios should return VoidResult") + _assert_true(clear_result is bool, "clear_transaction_ios should return bool") # get_pending_transactions_ios var pending = GodotIapPlugin.get_pending_transactions_ios() @@ -216,12 +279,20 @@ func test_ios_methods_mock() -> void: # present_code_redemption_sheet_ios var redemption_result = GodotIapPlugin.present_code_redemption_sheet_ios() - _assert_true(redemption_result is Types.VoidResult, "present_code_redemption_sheet_ios should return VoidResult") + _assert_true(redemption_result is bool, "present_code_redemption_sheet_ios should return bool") + + # request_purchase_on_promoted_product_ios + var promoted_result = GodotIapPlugin.request_purchase_on_promoted_product_ios() + _assert_true(promoted_result is bool, "request_purchase_on_promoted_product_ios should return bool") # current_entitlement_ios var entitlement = GodotIapPlugin.current_entitlement_ios("test_sku") _assert_true(entitlement == null or entitlement is Types.PurchaseIOS, "current_entitlement_ios should return PurchaseIOS or null") + # begin_refund_request_ios + var refund_status = await GodotIapPlugin.begin_refund_request_ios("test_sku") + _assert_true(refund_status is String, "begin_refund_request_ios should return String") + # latest_transaction_ios var latest = GodotIapPlugin.latest_transaction_ios("test_sku") _assert_true(latest == null or latest is Types.PurchaseIOS, "latest_transaction_ios should return PurchaseIOS or null") @@ -238,11 +309,23 @@ func test_ios_methods_mock() -> void: func test_android_methods_mock() -> void: # acknowledge_purchase_android var ack_result = GodotIapPlugin.acknowledge_purchase_android("mock_token") - _assert_true(ack_result is Types.VoidResult, "acknowledge_purchase_android should return VoidResult") + _assert_true(ack_result is bool, "acknowledge_purchase_android should return bool") # consume_purchase_android var consume_result = GodotIapPlugin.consume_purchase_android("mock_token") - _assert_true(consume_result is Types.VoidResult, "consume_purchase_android should return VoidResult") + _assert_true(consume_result is bool, "consume_purchase_android should return bool") + + # check_alternative_billing_availability_android + var alternative_available = GodotIapPlugin.check_alternative_billing_availability_android() + _assert_true(alternative_available is bool, "check_alternative_billing_availability_android should return bool") + + # show_alternative_billing_dialog_android + var alternative_accepted = GodotIapPlugin.show_alternative_billing_dialog_android() + _assert_true(alternative_accepted is bool, "show_alternative_billing_dialog_android should return bool") + + # create_alternative_billing_token_android + var alternative_token = GodotIapPlugin.create_alternative_billing_token_android() + _assert_true(alternative_token is String, "create_alternative_billing_token_android should return String") # get_package_name_android var package_name = GodotIapPlugin.get_package_name_android() @@ -252,6 +335,28 @@ func test_android_methods_mock() -> void: var has_subs = GodotIapPlugin.has_active_subscriptions() _assert_true(has_subs is bool, "has_active_subscriptions should return bool") + # deep_link_to_subscriptions + var deep_link_result = await GodotIapPlugin.deep_link_to_subscriptions() + _assert_true(deep_link_result is Types.VoidResult, "deep_link_to_subscriptions should return VoidResult") + + # get_billing_choice_info_android + var choice_params = Types.GetBillingChoiceInfoParamsAndroid.new() + choice_params.billing_program = Types.BillingProgramAndroid.BILLING_CHOICE + choice_params.play_billing_choice_image_layout = Types.BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE + var choice_info = GodotIapPlugin.get_billing_choice_info_android(choice_params) + _assert_true(choice_info is Types.BillingChoiceInfoAndroid, "get_billing_choice_info_android should return BillingChoiceInfoAndroid") + + # show_billing_program_information_dialog_android + var dialog_params = Types.BillingProgramInformationDialogParamsAndroid.new() + dialog_params.billing_program = Types.BillingProgramAndroid.BILLING_CHOICE + dialog_params.external_transaction_token = "mock_external_token" + var dialog_result = GodotIapPlugin.show_billing_program_information_dialog_android(dialog_params) + _assert_true(dialog_result is Types.BillingResultAndroid, "show_billing_program_information_dialog_android should return BillingResultAndroid") + + # show_in_app_messages_android + var message_result = GodotIapPlugin.show_in_app_messages_android() + _assert_true(message_result is Types.InAppMessageResultAndroid, "show_in_app_messages_android should return InAppMessageResultAndroid") + # ============================================ # Test Utilities diff --git a/libraries/godot-iap/Makefile b/libraries/godot-iap/Makefile index ae0066561..3452b9438 100644 --- a/libraries/godot-iap/Makefile +++ b/libraries/godot-iap/Makefile @@ -195,6 +195,7 @@ all: android ios macos test: @echo "$(GREEN)Running GDScript unit tests...$(NC)" @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_types_only.gd + @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_godot_iap.gd @echo "$(GREEN)✓ Tests complete$(NC)" # Clean build artifacts @@ -233,7 +234,9 @@ run-android: export-android # Export iOS Xcode project export-ios: ios @echo "$(GREEN)Exporting iOS Xcode project...$(NC)" + @rm -rf $(IOS_EXPORT_DIR) @mkdir -p $(IOS_EXPORT_DIR) + @touch $(IOS_EXPORT_DIR)/.gdignore @cd $(EXAMPLE_DIR) && $(GODOT) --headless --export-debug "iOS" ios/Martie.xcodeproj @echo "$(GREEN)Fixing iOS frameworks...$(NC)" @cp $(BIN_DIR)/ios/GodotIap.framework/Info.plist $(IOS_EXPORT_DIR)/Martie/addons/godot-iap/bin/ios/GodotIap.framework/ 2>/dev/null || true diff --git a/libraries/godot-iap/README.md b/libraries/godot-iap/README.md index f199ac2a6..d47cd9535 100644 --- a/libraries/godot-iap/README.md +++ b/libraries/godot-iap/README.md @@ -11,6 +11,8 @@ A comprehensive in-app purchase plugin for Godot 4.x that conforms to the Open IAP specification +Requires Godot 4.3+, iOS 17+ for the Swift GDExtension, or Android API 24+. + Open IAP @@ -75,7 +77,7 @@ godot-iap conforms to the **[OpenIAP specification](https://openiap.dev)** — a - **Shared specification** — Common types, error codes, and purchase flows across all platforms - **Generated type-safe bindings** — Swift, Kotlin, Dart, and GDScript from a single GraphQL schema -- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 8.x) +- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 9.1.0) - **Verification profiles** — Standardized receipt validation and purchase verification patterns Other libraries built on OpenIAP: [react-native-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap) · [expo-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap) · [flutter_inapp_purchase](https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase) · [kmp-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap) diff --git a/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar b/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar index 21db3895a..3944a37ca 100644 Binary files a/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar and b/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap b/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap index 36e4768be..616c0a0f6 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap and b/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/Info.plist b/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/Info.plist index 7cc8f2d57..2aa9c6cec 100644 Binary files a/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/Info.plist and b/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/Info.plist differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/Info.plist b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/Info.plist index cbe30919d..bf4ead4a7 100644 Binary files a/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/Info.plist and b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/Info.plist differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime index ba23fb5dd..91679d37f 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime and b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime differ diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index 7f1fe43e0..a935624e2 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -203,9 +203,9 @@ func _on_android_subscription_billing_issue(purchase_json: String) -> void: ## Initialize the store connection. Must be called before any other IAP API. ## -## This wrapper currently takes no arguments — Android billing-program flags -## (e.g. [code]enable_billing_program_android[/code]) live on the underlying -## [InitConnectionConfig] but are not yet plumbed through the GDScript API. +## [param config]: optional [InitConnectionConfig]. On Android, set +## [code]enable_billing_program_android[/code] to Billing Choice and use +## [code]billing_choice_screen_type_android[/code] to match Play Console. ## ## Returns [code]true[/code] once the platform billing client is connected. ## @@ -214,12 +214,16 @@ func _on_android_subscription_billing_issue(purchase_json: String) -> void: ## [/codeblock] ## ## See: https://openiap.dev/docs/apis/init-connection -func init_connection() -> bool: +func init_connection(config = null) -> bool: print("[GodotIap] init_connection called") if _native_plugin: if _platform == "Android": print("[GodotIap] Calling Android initConnection...") - _is_connected = _native_plugin.call("initConnection") + if config != null: + var config_dict = config.to_dict() if typeof(config) == TYPE_OBJECT and config.has_method("to_dict") else config + _is_connected = _native_plugin.call("initConnectionWithConfig", JSON.stringify(config_dict)) + else: + _is_connected = _native_plugin.call("initConnection") if not _is_connected: print("[GodotIap] ERROR: initConnection failed. Check Google Play Services and billing setup.") else: @@ -433,19 +437,28 @@ func _request_purchase_raw(args: Dictionary) -> Dictionary: "sku": google_props.get("skus", [])[0], "offerToken": str(offer_token), }] + var replacement_params = google_props.get("subscriptionProductReplacementParams", null) + if typeof(replacement_params) == TYPE_OBJECT and replacement_params.has_method("to_dict"): + replacement_params = replacement_params.to_dict() + var developer_billing_option = google_props.get("developerBillingOption", null) + if typeof(developer_billing_option) == TYPE_OBJECT and developer_billing_option.has_method("to_dict"): + developer_billing_option = developer_billing_option.to_dict() var params = { "type": purchase_type, "skus": google_props.get("skus", []), - "obfuscatedAccountIdAndroid": google_props.get("obfuscatedAccountIdAndroid", ""), - "obfuscatedProfileIdAndroid": google_props.get("obfuscatedProfileIdAndroid", ""), + "obfuscatedAccountId": google_props.get("obfuscatedAccountId", google_props.get("obfuscatedAccountIdAndroid", "")), + "obfuscatedProfileId": google_props.get("obfuscatedProfileId", google_props.get("obfuscatedProfileIdAndroid", "")), "isOfferPersonalized": google_props.get("isOfferPersonalized", false), "offerTokenArr": offer_token_arr, "subscriptionOffers": subscription_offers, - "purchaseTokenAndroid": google_props.get("purchaseTokenAndroid", ""), - "replacementModeAndroid": google_props.get("replacementModeAndroid", 0), + "purchaseToken": google_props.get("purchaseToken", google_props.get("purchaseTokenAndroid", "")), + "originalExternalTransactionId": google_props.get("originalExternalTransactionId", ""), + "replacementMode": google_props.get("replacementMode", google_props.get("replacementModeAndroid", 0)), + "subscriptionProductReplacementParams": replacement_params, + "developerBillingOption": developer_billing_option, } var params_json = JSON.stringify(params) - print("[GodotIap] Calling Android requestPurchase: type=", purchase_type, ", skus=", params["skus"].size(), ", subscriptionOffers=", params["subscriptionOffers"].size(), ", hasPurchaseToken=", not str(params["purchaseTokenAndroid"]).is_empty()) + print("[GodotIap] Calling Android requestPurchase: type=", purchase_type, ", skus=", params["skus"].size(), ", subscriptionOffers=", params["subscriptionOffers"].size(), ", hasPurchaseToken=", not str(params["purchaseToken"]).is_empty()) result_raw = _native_plugin.call("requestPurchaseJson", params_json) elif _platform == "iOS": var apple_props = request.get("apple", request.get("ios", {})) @@ -812,32 +825,28 @@ func _verify_purchase_with_provider_raw(props: Dictionary) -> Dictionary: # ========================================== ## Sync with App Store (iOS only). -## @return Types.VoidResult +## @return bool - true if the sync request completed successfully ## ## See: https://openiap.dev/docs/apis/ios/sync-ios -func sync_ios() -> Variant: - var result = Types.VoidResult.new() - result.success = false - if _native_plugin and _platform == "iOS": - var result_json = _native_plugin.call("syncIOS") - var parsed = JSON.parse_string(result_json) - if parsed is Dictionary: - return Types.VoidResult.from_dict(parsed) - return result +func sync_ios() -> bool: + if not (_native_plugin and _platform == "iOS"): + return false + var pending = _native_plugin.call("syncIOS") + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("syncIOS", request_id) + return payload.get("success", false) ## Clear pending transactions from the StoreKit payment queue (iOS only). -## @return Types.VoidResult +## @return bool - true if pending transactions were cleared successfully ## ## See: https://openiap.dev/docs/apis/ios/clear-transaction-ios -func clear_transaction_ios() -> Variant: - var result = Types.VoidResult.new() - result.success = false - if _native_plugin and _platform == "iOS": - var result_json = _native_plugin.call("clearTransactionIOS") - var parsed = JSON.parse_string(result_json) - if parsed is Dictionary: - return Types.VoidResult.from_dict(parsed) - return result +func clear_transaction_ios() -> bool: + if not (_native_plugin and _platform == "iOS"): + return false + var pending = _native_plugin.call("clearTransactionIOS") + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("clearTransactionIOS", request_id) + return payload.get("success", false) ## Get pending transactions (iOS only). ## @return Array[Types.PurchaseIOS] @@ -877,18 +886,16 @@ func get_all_transactions_ios() -> Array: return purchases ## Present code redemption sheet (iOS only). -## @return Types.VoidResult +## @return bool - true if the sheet was presented successfully ## ## See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios -func present_code_redemption_sheet_ios() -> Variant: - var result = Types.VoidResult.new() - result.success = false - if _native_plugin and _platform == "iOS": - var result_json = _native_plugin.call("presentCodeRedemptionSheetIOS") - var parsed = JSON.parse_string(result_json) - if parsed is Dictionary: - return Types.VoidResult.from_dict(parsed) - return result +func present_code_redemption_sheet_ios() -> bool: + if not (_native_plugin and _platform == "iOS"): + return false + var pending = _native_plugin.call("presentCodeRedemptionSheetIOS") + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("presentCodeRedemptionSheetIOS", request_id) + return payload.get("success", false) ## Show manage subscriptions UI (iOS only). ## @return Array[Types.PurchaseIOS] - changed purchases @@ -910,17 +917,18 @@ func show_manage_subscriptions_ios() -> Array: ## Begin refund request (iOS only). ## @param product_id: String - the product ID to request refund for -## @return Types.RefundResultIOS +## @return String - refund request status, or empty string on failure ## ## See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios -func begin_refund_request_ios(product_id: String) -> Variant: - if _native_plugin and _platform == "iOS": - var result_json = _native_plugin.call("beginRefundRequestIOS", product_id) - var result = JSON.parse_string(result_json) - if result is Dictionary: - return Types.RefundResultIOS.from_dict(result) - var default_result = Types.RefundResultIOS.new() - return default_result +func begin_refund_request_ios(product_id: String) -> String: + if not (_native_plugin and _platform == "iOS"): + return "" + var pending = _native_plugin.call("beginRefundRequestIOS", product_id) + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("beginRefundRequestIOS", request_id) + if payload.get("success", false): + return payload.get("status", "") + return "" ## Get current entitlement for a product (iOS only). ## @param sku: String - product SKU @@ -1020,18 +1028,16 @@ func get_promoted_product_ios() -> Variant: return null ## Request purchase on promoted product (iOS only). -## @return Types.VoidResult +## @return bool - true if the promoted product purchase request succeeded ## ## See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios -func request_purchase_on_promoted_product_ios() -> Variant: - var result = Types.VoidResult.new() - result.success = false - if _native_plugin and _platform == "iOS": - var result_json = _native_plugin.call("requestPurchaseOnPromotedProductIOS") - var parsed = JSON.parse_string(result_json) - if parsed is Dictionary: - return Types.VoidResult.from_dict(parsed) - return result +func request_purchase_on_promoted_product_ios() -> bool: + if not (_native_plugin and _platform == "iOS"): + return false + var pending = _native_plugin.call("requestPurchaseOnPromotedProductIOS") + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("requestPurchaseOnPromotedProductIOS", request_id) + return payload.get("success", false) ## Check if can present external purchase notice (iOS 18.2+). ## @return bool - true if external purchase notice can be presented @@ -1255,12 +1261,12 @@ func show_external_purchase_custom_link_notice_ios(notice_type: String) -> Varia ## Acknowledge a purchase (Android only, for non-consumables). ## @param purchase_token: String - the purchase token to acknowledge -## @return Types.VoidResult +## @return bool - true if the purchase was acknowledged successfully ## ## See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android -func acknowledge_purchase_android(purchase_token: String) -> Variant: +func acknowledge_purchase_android(purchase_token: String) -> bool: var result = _acknowledge_purchase_android_raw(purchase_token) - return Types.VoidResult.from_dict(result) + return result.get("success", false) ## Internal: Acknowledge purchase raw func _acknowledge_purchase_android_raw(purchase_token: String) -> Dictionary: @@ -1276,12 +1282,12 @@ func _acknowledge_purchase_android_raw(purchase_token: String) -> Dictionary: ## Consume a purchase (Android only, for consumables). ## @param purchase_token: String - the purchase token to consume -## @return Types.VoidResult +## @return bool - true if the purchase was consumed successfully ## ## See: https://openiap.dev/docs/apis/android/consume-purchase-android -func consume_purchase_android(purchase_token: String) -> Variant: +func consume_purchase_android(purchase_token: String) -> bool: var result = _consume_purchase_android_raw(purchase_token) - return Types.VoidResult.from_dict(result) + return result.get("success", false) ## Internal: Consume purchase raw func _consume_purchase_android_raw(purchase_token: String) -> Dictionary: @@ -1296,44 +1302,40 @@ func _consume_purchase_android_raw(purchase_token: String) -> Dictionary: return { "success": false, "error": "Not available" } ## Check alternative billing availability (Android). -## @return Types.BillingProgramAvailabilityResultAndroid +## @return bool - true if alternative billing is available ## ## See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android -func check_alternative_billing_availability_android() -> Variant: +func check_alternative_billing_availability_android() -> bool: if _native_plugin and _platform == "Android": var result_json = _native_plugin.call("checkAlternativeBillingAvailabilityAndroid") var result = JSON.parse_string(result_json) if result is Dictionary: - return Types.BillingProgramAvailabilityResultAndroid.from_dict(result) - var default_result = Types.BillingProgramAvailabilityResultAndroid.new() - default_result.is_available = false - return default_result + return result.get("isAvailable", false) + return false ## Show alternative billing dialog (Android). -## @return Types.UserChoiceBillingDetails +## @return bool - true if the user accepted the dialog ## ## See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android -func show_alternative_billing_dialog_android() -> Variant: +func show_alternative_billing_dialog_android() -> bool: if _native_plugin and _platform == "Android": var result_json = _native_plugin.call("showAlternativeBillingDialogAndroid") var result = JSON.parse_string(result_json) if result is Dictionary: - return Types.UserChoiceBillingDetails.from_dict(result) - var default_result = Types.UserChoiceBillingDetails.new() - return default_result + return result.get("userAccepted", false) + return false ## Create alternative billing token (Android). -## @return Types.BillingProgramReportingDetailsAndroid +## @return String - reporting token, or empty string on failure ## ## See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android -func create_alternative_billing_token_android() -> Variant: +func create_alternative_billing_token_android() -> String: if _native_plugin and _platform == "Android": var result_json = _native_plugin.call("createAlternativeBillingTokenAndroid") var result = JSON.parse_string(result_json) - if result is Dictionary: - return Types.BillingProgramReportingDetailsAndroid.from_dict(result) - var default_result = Types.BillingProgramReportingDetailsAndroid.new() - return default_result + if result is Dictionary and result.get("success", false): + return result.get("token", "") + return "" ## Check if a billing program is available (Android 8.2.0+). ## @param billing_program: Types.BillingProgramAndroid - billing program enum value @@ -1342,7 +1344,7 @@ func create_alternative_billing_token_android() -> Variant: ## See: https://openiap.dev/docs/apis/android/is-billing-program-available-android func is_billing_program_available_android(billing_program) -> Variant: if _native_plugin and _platform == "Android": - var result_json = _native_plugin.call("isBillingProgramAvailableAndroid", billing_program) + var result_json = _native_plugin.call("isBillingProgramAvailableAndroid", _billing_program_to_raw(billing_program)) var result = JSON.parse_string(result_json) if result is Dictionary: return Types.BillingProgramAvailabilityResultAndroid.from_dict(result) @@ -1351,30 +1353,60 @@ func is_billing_program_available_android(billing_program) -> Variant: default_result.billing_program = billing_program return default_result +func _billing_program_to_raw(billing_program) -> Variant: + if typeof(billing_program) == TYPE_INT and Types.BILLING_PROGRAM_ANDROID_VALUES.has(billing_program): + return Types.BILLING_PROGRAM_ANDROID_VALUES[billing_program] + return billing_program + +func _developer_billing_type_to_raw(developer_billing_type) -> Variant: + if typeof(developer_billing_type) == TYPE_INT and Types.DEVELOPER_BILLING_TYPE_ANDROID_VALUES.has(developer_billing_type): + return Types.DEVELOPER_BILLING_TYPE_ANDROID_VALUES[developer_billing_type] + return developer_billing_type + +## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens (Android 9.1.0+). +## @param params: Types.GetBillingChoiceInfoParamsAndroid - Billing Choice info parameters +## @return Types.BillingChoiceInfoAndroid +## +## See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android +func get_billing_choice_info_android(params) -> Variant: + if _native_plugin and _platform == "Android": + var params_json = JSON.stringify(params.to_dict()) + var result_json = _native_plugin.call("getBillingChoiceInfoAndroid", params_json) + var result = JSON.parse_string(result_json) + if result is Dictionary: + return Types.BillingChoiceInfoAndroid.from_dict(result) + return Types.BillingChoiceInfoAndroid.new() + ## Launch external link (Android 8.2.0+). ## @param params: Types.LaunchExternalLinkParamsAndroid - external link parameters -## @return Types.VoidResult +## @return bool - true if the external link flow was accepted/launched ## ## See: https://openiap.dev/docs/apis/android/launch-external-link-android -func launch_external_link_android(params) -> Variant: +func launch_external_link_android(params) -> bool: if _native_plugin and _platform == "Android": var params_json = JSON.stringify(params.to_dict()) var result_json = _native_plugin.call("launchExternalLinkAndroid", params_json) var result = JSON.parse_string(result_json) if result is Dictionary: - return Types.VoidResult.from_dict(result) - var default_result = Types.VoidResult.new() - default_result.success = false - return default_result + return bool(result.get("launched", result.get("success", false))) + return false ## Create billing program reporting details (Android 8.2.0+). ## @param billing_program: Types.BillingProgramAndroid - billing program enum value +## @param developer_billing_type: Types.DeveloperBillingTypeAndroid or null ## @return Types.BillingProgramReportingDetailsAndroid ## ## See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android -func create_billing_program_reporting_details_android(billing_program) -> Variant: +func create_billing_program_reporting_details_android(billing_program, developer_billing_type = null) -> Variant: if _native_plugin and _platform == "Android": - var result_json = _native_plugin.call("createBillingProgramReportingDetailsAndroid", billing_program) + var result_json: String + if developer_billing_type == null: + result_json = _native_plugin.call("createBillingProgramReportingDetailsAndroid", _billing_program_to_raw(billing_program)) + else: + result_json = _native_plugin.call("createBillingProgramReportingDetailsAndroidWithType", JSON.stringify({ + "billingProgram": _billing_program_to_raw(billing_program), + "developerBillingType": _developer_billing_type_to_raw(developer_billing_type) + })) var result = JSON.parse_string(result_json) if result is Dictionary: return Types.BillingProgramReportingDetailsAndroid.from_dict(result) @@ -1382,6 +1414,36 @@ func create_billing_program_reporting_details_android(billing_program) -> Varian default_result.billing_program = billing_program return default_result +## Show Google's mandatory information dialog before a developer-rendered, +## in-app Billing Choice screen (Android 9.1.0+). +## @param params: Types.BillingProgramInformationDialogParamsAndroid +## @return Types.BillingResultAndroid +## +## See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android +func show_billing_program_information_dialog_android(params) -> Variant: + if _native_plugin and _platform == "Android": + var result_json = _native_plugin.call("showBillingProgramInformationDialogAndroid", JSON.stringify(params.to_dict())) + var result = JSON.parse_string(result_json) + if result is Dictionary: + return Types.BillingResultAndroid.from_dict(result) + return Types.BillingResultAndroid.new() + +## Show Play Billing in-app messages (Android). +## @param params: Types.InAppMessageParamsAndroid or null +## @return Types.InAppMessageResultAndroid +## +## See: https://openiap.dev/docs/apis/android/show-in-app-messages-android +func show_in_app_messages_android(params = null) -> Variant: + if _native_plugin and _platform == "Android": + var params_dict = params.to_dict() if params != null and params is Object and params.has_method("to_dict") else {} + var result_json = _native_plugin.call("showInAppMessagesAndroid", JSON.stringify(params_dict)) + var result = JSON.parse_string(result_json) + if result is Dictionary: + return Types.InAppMessageResultAndroid.from_dict(result) + var default_result = Types.InAppMessageResultAndroid.new() + default_result.response_code = Types.InAppMessageResponseCodeAndroid.NO_ACTION_NEEDED + return default_result + ## Get the package name (Android only). ## @return String - Android package name func get_package_name_android() -> String: @@ -1395,17 +1457,29 @@ func get_package_name_android() -> String: ## Open subscription management deep link. ## @param options: Types.DeepLinkOptions or null - optional deep link configuration -## @return void +## @return Types.VoidResult ## ## See: https://openiap.dev/docs/apis/deep-link-to-subscriptions -func deep_link_to_subscriptions(options = null) -> void: +func deep_link_to_subscriptions(options = null) -> Variant: var opts = options if options != null else Types.DeepLinkOptions.new() - if _native_plugin and (_platform == "Android" or _platform == "iOS"): - var options_json = JSON.stringify(opts.to_dict()) - _native_plugin.call("deepLinkToSubscriptions", options_json) + if _native_plugin and _platform == "Android": + var android_options_json = JSON.stringify(opts.to_dict()) + var android_result_json = _native_plugin.call("deepLinkToSubscriptions", android_options_json) + var android_result = JSON.parse_string(android_result_json) + if android_result is Dictionary: + return Types.VoidResult.from_dict(android_result) + elif _native_plugin and _platform == "iOS": + var ios_options_json = JSON.stringify(opts.to_dict()) + var ios_pending = _native_plugin.call("deepLinkToSubscriptions", ios_options_json) + var ios_request_id = _parse_request_id(ios_pending) + var ios_payload = await _await_products_fetched_for("deepLinkToSubscriptions", ios_request_id) + return Types.VoidResult.from_dict(ios_payload) elif _platform == "iOS": # iOS: Open App Store subscription management URL OS.shell_open("https://apps.apple.com/account/subscriptions") + var ios_fallback_result = Types.VoidResult.new() + ios_fallback_result.success = true + return ios_fallback_result elif _platform == "Android": # Android: Open Play Store subscription management URL var sku = opts.sku_android if opts.sku_android else "" @@ -1416,6 +1490,12 @@ func deep_link_to_subscriptions(options = null) -> void: OS.shell_open("https://play.google.com/store/account/subscriptions?sku=%s&package=%s" % [encoded_sku, encoded_package]) else: OS.shell_open("https://play.google.com/store/account/subscriptions") + var android_fallback_result = Types.VoidResult.new() + android_fallback_result.success = true + return android_fallback_result + var unavailable_result = Types.VoidResult.new() + unavailable_result.success = false + return unavailable_result # ========================================== # Utility Functions diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index f36b8f261..28193ce4e 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -21,7 +21,27 @@ enum AlternativeBillingModeAndroid { ALTERNATIVE_ONLY = 2, } -## Billing program types for external content links, external offers, and external payments (Android) Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +## Play Billing choice image layout (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum BillingChoiceImageLayoutAndroid { + ## Rectangular image with a 4:1 aspect ratio. + RECTANGULAR_FOUR_BY_ONE = 0, + ## Rectangular image with a 3:1 aspect ratio. + RECTANGULAR_THREE_BY_ONE = 1, + ## Rectangular image with a 2:2 aspect ratio. + RECTANGULAR_TWO_BY_TWO = 2, +} + +## Choice screen renderer for Billing Choice availability (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum BillingChoiceScreenTypeAndroid { + ## Unspecified choice screen type. + UNSPECIFIED = 0, + ## Choice screen is rendered by the developer app. + DEVELOPER_RENDERED = 1, + ## Choice screen is rendered by Google Play. + GOOGLE_RENDERED = 2, +} + +## Billing program types for Google Play Billing Programs (Android) Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). enum BillingProgramAndroid { ## Unspecified billing program. Do not use. UNSPECIFIED = 0, @@ -33,6 +53,8 @@ enum BillingProgramAndroid { EXTERNAL_OFFER = 3, ## External Payments program (Japan only). Allows presenting a side-by-side choice between Google Play Billing and developer's external payment option. Users can choose to complete the purchase on the developer's website. Available in Google Play Billing Library 8.3.0+ EXTERNAL_PAYMENTS = 4, + ## Billing Choice program. Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + BILLING_CHOICE = 5, } ## Launch mode for developer billing option (Android) Determines how the external payment URL is launched Available in Google Play Billing Library 8.3.0+ @@ -45,6 +67,16 @@ enum DeveloperBillingLaunchModeAndroid { CALLER_WILL_LAUNCH_LINK = 2, } +## Developer-provided billing destination type for Billing Program reporting details (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum DeveloperBillingTypeAndroid { + ## Unspecified developer billing type. Do not use. + DEVELOPER_BILLING_TYPE_UNSPECIFIED = 0, + ## Developer-provided billing via native in-app experience. + IN_APP = 1, + ## Developer-provided billing via external link or embedded web browsing. + EXTERNAL_LINK = 2, +} + ## Discount offer type enumeration. Categorizes the type of discount or promotional offer. enum DiscountOfferType { ## Introductory offer for new subscribers (first-time purchase discount) @@ -144,7 +176,7 @@ enum IapEvent { PURCHASE_ERROR = 1, PROMOTED_PRODUCT_IOS = 2, USER_CHOICE_BILLING_ANDROID = 3, - ## Fired when user selects developer-provided billing option in external payments flow. Available on Android with Google Play Billing Library 8.3.0+ + ## Fired for External Payments (8.3.0+) and Google-rendered Billing Choice developer billing selections on Android. Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). DEVELOPER_PROVIDED_BILLING_ANDROID = 4, ## Fired when an active subscription enters a billing-issue state that requires user attention. Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing Compatibility SDK implements only the Play Billing 7.0 API surface. SUBSCRIPTION_BILLING_ISSUE = 5, @@ -185,6 +217,22 @@ enum IapStore { AMAZON = 4, } +## High-level in-app message category (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). +enum InAppMessageCategoryAndroid { + ## Unknown in-app message category. + UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID = 0, + ## Transactional billing messages, such as payment issues or pending price-change confirmations. + TRANSACTIONAL = 1, +} + +## Response code from Play billing in-app messages (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). +enum InAppMessageResponseCodeAndroid { + ## Flow finished and no developer action is needed. + NO_ACTION_NEEDED = 0, + ## Subscription status changed and the purchase token should be checked. + SUBSCRIPTION_STATUS_UPDATED = 1, +} + ## Payment mode for subscription offers. Determines how the user pays during the offer period. enum PaymentMode { ## Free trial period - no charge during offer @@ -388,6 +436,7 @@ class ActiveSubscription: var days_until_expiration_ios: Variant = null var transaction_id: String = "" var purchase_token: Variant = null + ## Unix timestamp in milliseconds since January 1, 1970 UTC. var transaction_date: float = 0.0 var base_plan_id_android: Variant = null ## Required for subscription upgrade/downgrade on Android @@ -684,12 +733,38 @@ class AppTransaction: dict["originalPlatform"] = original_platform return dict +## Display information for developer-rendered Billing Choice screens (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class BillingChoiceInfoAndroid: + ## URL for the Play Billing choice image matching the requested layout. + var play_billing_choice_image_url: String = "" + ## Play Loyalty information for the user. + var play_billing_loyalty_info: Variant = null + + static func from_dict(data: Dictionary) -> BillingChoiceInfoAndroid: + var obj = BillingChoiceInfoAndroid.new() + if data.has("playBillingChoiceImageUrl") and data["playBillingChoiceImageUrl"] != null: + obj.play_billing_choice_image_url = data["playBillingChoiceImageUrl"] + if data.has("playBillingLoyaltyInfo") and data["playBillingLoyaltyInfo"] != null: + obj.play_billing_loyalty_info = data["playBillingLoyaltyInfo"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["playBillingChoiceImageUrl"] = play_billing_choice_image_url + if play_billing_loyalty_info != null: + dict["playBillingLoyaltyInfo"] = play_billing_loyalty_info + return dict + ## Result of checking billing program availability (Android) Available in Google Play Billing Library 8.2.0+ class BillingProgramAvailabilityResultAndroid: ## Whether the billing program is available for the user var is_available: bool = false ## The billing program that was checked var billing_program: BillingProgramAndroid + ## Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + var choice_screen_type: BillingChoiceScreenTypeAndroid + ## Whether external-link payment is available for Billing Choice. + var is_external_link_available: Variant = null static func from_dict(data: Dictionary) -> BillingProgramAvailabilityResultAndroid: var obj = BillingProgramAvailabilityResultAndroid.new() @@ -701,6 +776,14 @@ class BillingProgramAvailabilityResultAndroid: obj.billing_program = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] else: obj.billing_program = enum_str + if data.has("choiceScreenType") and data["choiceScreenType"] != null: + var enum_str = data["choiceScreenType"] + if enum_str is String and BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING.has(enum_str): + obj.choice_screen_type = BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING[enum_str] + else: + obj.choice_screen_type = enum_str + if data.has("isExternalLinkAvailable") and data["isExternalLinkAvailable"] != null: + obj.is_external_link_available = data["isExternalLinkAvailable"] return obj func to_dict() -> Dictionary: @@ -710,6 +793,12 @@ class BillingProgramAvailabilityResultAndroid: dict["billingProgram"] = BILLING_PROGRAM_ANDROID_VALUES[billing_program] else: dict["billingProgram"] = billing_program + if BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES.has(choice_screen_type): + dict["choiceScreenType"] = BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES[choice_screen_type] + else: + dict["choiceScreenType"] = choice_screen_type + if is_external_link_available != null: + dict["isExternalLinkAvailable"] = is_external_link_available return dict ## Reporting details for transactions made outside of Google Play Billing (Android) Contains the external transaction token needed for reporting Available in Google Play Billing Library 8.2.0+ @@ -777,17 +866,85 @@ class BillingResultAndroid: ## Details provided when user selects developer billing option (Android) Received via DeveloperProvidedBillingListener callback Available in Google Play Billing Library 8.3.0+ class DeveloperProvidedBillingDetailsAndroid: ## External transaction token used to report transactions made through developer billing. - var external_transaction_token: String = "" + var external_transaction_token: Variant = null + ## URI to launch for an external-link Billing Choice flow, when provided by + var link_uri: Variant = null + ## Original external transaction ID when replacing a subscription that was + var original_external_transaction_id: Variant = null + ## Products selected for the developer billing flow. + var products: Array[DeveloperProvidedBillingProductAndroid] = [] static func from_dict(data: Dictionary) -> DeveloperProvidedBillingDetailsAndroid: var obj = DeveloperProvidedBillingDetailsAndroid.new() if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: obj.external_transaction_token = data["externalTransactionToken"] + if data.has("linkUri") and data["linkUri"] != null: + obj.link_uri = data["linkUri"] + if data.has("originalExternalTransactionId") and data["originalExternalTransactionId"] != null: + obj.original_external_transaction_id = data["originalExternalTransactionId"] + if data.has("products") and data["products"] != null: + if data["products"] is Array: + var arr: Array[DeveloperProvidedBillingProductAndroid] = [] + for item in data["products"]: + if item is Dictionary: + arr.append(DeveloperProvidedBillingProductAndroid.from_dict(item)) + elif item is DeveloperProvidedBillingProductAndroid: + arr.append(item) + obj.products = arr return obj func to_dict() -> Dictionary: var dict = {} - dict["externalTransactionToken"] = external_transaction_token + if external_transaction_token != null: + dict["externalTransactionToken"] = external_transaction_token + if link_uri != null: + dict["linkUri"] = link_uri + if original_external_transaction_id != null: + dict["originalExternalTransactionId"] = original_external_transaction_id + if products != null: + var arr = [] + for item in products: + if item != null and item.has_method("to_dict"): + arr.append(item.to_dict()) + else: + arr.append(item) + dict["products"] = arr + else: + dict["products"] = null + return dict + +## Product selected for developer-provided billing (Android 9.0+). +class DeveloperProvidedBillingProductAndroid: + ## Product identifier. + var id: String = "" + ## Google Play product type (in-app or subscription). + var type: ProductType + ## Subscription offer token, when applicable. + var offer_token: Variant = null + + static func from_dict(data: Dictionary) -> DeveloperProvidedBillingProductAndroid: + var obj = DeveloperProvidedBillingProductAndroid.new() + if data.has("id") and data["id"] != null: + obj.id = data["id"] + if data.has("type") and data["type"] != null: + var enum_str = data["type"] + if enum_str is String and PRODUCT_TYPE_FROM_STRING.has(enum_str): + obj.type = PRODUCT_TYPE_FROM_STRING[enum_str] + else: + obj.type = enum_str + if data.has("offerToken") and data["offerToken"] != null: + obj.offer_token = data["offerToken"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["id"] = id + if PRODUCT_TYPE_VALUES.has(type): + dict["type"] = PRODUCT_TYPE_VALUES[type] + else: + dict["type"] = type + if offer_token != null: + dict["offerToken"] = offer_token return dict ## Discount amount details for one-time purchase offers (Android) Available in Google Play Billing Library 7.0+ @@ -1216,6 +1373,35 @@ class ExternalPurchaseNoticeResultIOS: dict["externalPurchaseToken"] = external_purchase_token return dict +## Result from showing Play billing in-app messages (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). +class InAppMessageResultAndroid: + ## Response code for the in-app messaging flow. + var response_code: InAppMessageResponseCodeAndroid + ## Purchase token returned when a subscription status changed. + var purchase_token: Variant = null + + static func from_dict(data: Dictionary) -> InAppMessageResultAndroid: + var obj = InAppMessageResultAndroid.new() + if data.has("responseCode") and data["responseCode"] != null: + var enum_str = data["responseCode"] + if enum_str is String and IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_FROM_STRING.has(enum_str): + obj.response_code = IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_FROM_STRING[enum_str] + else: + obj.response_code = enum_str + if data.has("purchaseToken") and data["purchaseToken"] != null: + obj.purchase_token = data["purchaseToken"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_VALUES.has(response_code): + dict["responseCode"] = IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_VALUES[response_code] + else: + dict["responseCode"] = response_code + if purchase_token != null: + dict["purchaseToken"] = purchase_token + return dict + ## Installment plan details for subscription offers (Android) Contains information about the installment plan commitment. Available in Google Play Billing Library 7.0+ class InstallmentPlanDetailsAndroid: ## Committed payments count after a user signs up for this subscription plan. @@ -2239,6 +2425,7 @@ class PurchaseAndroid: var product_id: String = "" var ids: Array[String] = [] var transaction_id: Variant = null + ## Unix timestamp in milliseconds since January 1, 1970 UTC. var transaction_date: float = 0.0 var purchase_token: Variant = null ## Store where purchase was made @@ -2442,6 +2629,7 @@ class PurchaseIOS: var id: String = "" var product_id: String = "" var ids: Array[String] = [] + ## Unix timestamp in milliseconds since January 1, 1970 UTC. var transaction_date: float = 0.0 var purchase_token: Variant = null ## Store where purchase was made @@ -3836,6 +4024,36 @@ class AndroidSubscriptionOfferInput: dict["offerToken"] = offer_token return dict +## Parameters for showing a billing program information dialog (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class BillingProgramInformationDialogParamsAndroid: + ## Billing program. Currently only BILLING_CHOICE is supported. + var billing_program: BillingProgramAndroid = BillingProgramAndroid.BILLING_CHOICE + ## External transaction token returned by the Billing Choice reporting-details flow. + var external_transaction_token: String = "" + + static func from_dict(data: Dictionary) -> BillingProgramInformationDialogParamsAndroid: + var obj = BillingProgramInformationDialogParamsAndroid.new() + if data.has("billingProgram") and data["billingProgram"] != null: + var enum_str = data["billingProgram"] + if enum_str is String and BILLING_PROGRAM_ANDROID_FROM_STRING.has(enum_str): + obj.billing_program = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] + else: + obj.billing_program = enum_str + if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: + obj.external_transaction_token = data["externalTransactionToken"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if billing_program != null: + if BILLING_PROGRAM_ANDROID_VALUES.has(billing_program): + dict["billingProgram"] = BILLING_PROGRAM_ANDROID_VALUES[billing_program] + else: + dict["billingProgram"] = billing_program + if external_transaction_token != null: + dict["externalTransactionToken"] = external_transaction_token + return dict + class DeepLinkOptions: ## Android SKU to open (required on Android) var sku_android: Variant = null @@ -3858,14 +4076,16 @@ class DeepLinkOptions: dict["packageNameAndroid"] = package_name_android return dict -## Parameters for developer billing option in purchase flow (Android) Used with BillingFlowParams to enable external payments flow Available in Google Play Billing Library 8.3.0+ +## Parameters for a developer billing option in a purchase flow (Android). Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). Only billingProgram is required; link fields are used when the selected program links outside the app. class DeveloperBillingOptionParamsAndroid: - ## The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + ## The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. var billing_program: BillingProgramAndroid - ## The URI where the external payment will be processed - var link_uri: String = "" - ## The launch mode for the external payment link + ## The URI where the external payment will be processed. + var link_uri: Variant = null + ## The launch mode for the external payment link. var launch_mode: DeveloperBillingLaunchModeAndroid + ## A pre-generated external transaction token for a Billing Choice external-link + var external_transaction_token: Variant = null static func from_dict(data: Dictionary) -> DeveloperBillingOptionParamsAndroid: var obj = DeveloperBillingOptionParamsAndroid.new() @@ -3883,6 +4103,8 @@ class DeveloperBillingOptionParamsAndroid: obj.launch_mode = DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_FROM_STRING[enum_str] else: obj.launch_mode = enum_str + if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: + obj.external_transaction_token = data["externalTransactionToken"] return obj func to_dict() -> Dictionary: @@ -3899,6 +4121,8 @@ class DeveloperBillingOptionParamsAndroid: dict["launchMode"] = DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_VALUES[launch_mode] else: dict["launchMode"] = launch_mode + if external_transaction_token != null: + dict["externalTransactionToken"] = external_transaction_token return dict class DiscountOfferInputIOS: @@ -3941,12 +4165,87 @@ class DiscountOfferInputIOS: dict["timestamp"] = timestamp return dict +## Parameters for fetching Billing Choice display information (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class GetBillingChoiceInfoParamsAndroid: + ## Billing program. Currently only BILLING_CHOICE is supported. + var billing_program: BillingProgramAndroid = BillingProgramAndroid.BILLING_CHOICE + ## Desired Play Billing choice image layout. + var play_billing_choice_image_layout: BillingChoiceImageLayoutAndroid = BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE + ## BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + var user_locale: Variant = null + + static func from_dict(data: Dictionary) -> GetBillingChoiceInfoParamsAndroid: + var obj = GetBillingChoiceInfoParamsAndroid.new() + if data.has("billingProgram") and data["billingProgram"] != null: + var enum_str = data["billingProgram"] + if enum_str is String and BILLING_PROGRAM_ANDROID_FROM_STRING.has(enum_str): + obj.billing_program = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] + else: + obj.billing_program = enum_str + if data.has("playBillingChoiceImageLayout") and data["playBillingChoiceImageLayout"] != null: + var enum_str = data["playBillingChoiceImageLayout"] + if enum_str is String and BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_FROM_STRING.has(enum_str): + obj.play_billing_choice_image_layout = BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_FROM_STRING[enum_str] + else: + obj.play_billing_choice_image_layout = enum_str + if data.has("userLocale") and data["userLocale"] != null: + obj.user_locale = data["userLocale"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if billing_program != null: + if BILLING_PROGRAM_ANDROID_VALUES.has(billing_program): + dict["billingProgram"] = BILLING_PROGRAM_ANDROID_VALUES[billing_program] + else: + dict["billingProgram"] = billing_program + if play_billing_choice_image_layout != null: + if BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_VALUES.has(play_billing_choice_image_layout): + dict["playBillingChoiceImageLayout"] = BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_VALUES[play_billing_choice_image_layout] + else: + dict["playBillingChoiceImageLayout"] = play_billing_choice_image_layout + if user_locale != null: + dict["userLocale"] = user_locale + return dict + +## Parameters for showing Play billing in-app messages (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). +class InAppMessageParamsAndroid: + ## In-app message categories to show. Defaults to transactional messages. + var categories: Array[InAppMessageCategoryAndroid] = [] + + static func from_dict(data: Dictionary) -> InAppMessageParamsAndroid: + var obj = InAppMessageParamsAndroid.new() + if data.has("categories") and data["categories"] != null: + if data["categories"] is Array: + var arr: Array[InAppMessageCategoryAndroid] = [] + for item in data["categories"]: + if item is String and IN_APP_MESSAGE_CATEGORY_ANDROID_FROM_STRING.has(item): + arr.append(IN_APP_MESSAGE_CATEGORY_ANDROID_FROM_STRING[item]) + elif item is int: + arr.append(item) + obj.categories = arr + return obj + + func to_dict() -> Dictionary: + var dict = {} + if categories != null: + var arr = [] + for item in categories: + if IN_APP_MESSAGE_CATEGORY_ANDROID_VALUES.has(item): + arr.append(IN_APP_MESSAGE_CATEGORY_ANDROID_VALUES[item]) + else: + arr.append(item) + dict["categories"] = arr + return dict + ## Connection initialization configuration class InitConnectionConfig: ## Alternative billing mode for Android var alternative_billing_mode_android: AlternativeBillingModeAndroid ## Enable a specific billing program for Android (7.0+) var enable_billing_program_android: BillingProgramAndroid + ## Billing Choice renderer configured in Play Console. Available in OpenIAP + var billing_choice_screen_type_android: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED static func from_dict(data: Dictionary) -> InitConnectionConfig: var obj = InitConnectionConfig.new() @@ -3962,6 +4261,12 @@ class InitConnectionConfig: obj.enable_billing_program_android = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] else: obj.enable_billing_program_android = enum_str + if data.has("billingChoiceScreenTypeAndroid") and data["billingChoiceScreenTypeAndroid"] != null: + var enum_str = data["billingChoiceScreenTypeAndroid"] + if enum_str is String and BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING.has(enum_str): + obj.billing_choice_screen_type_android = BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING[enum_str] + else: + obj.billing_choice_screen_type_android = enum_str return obj func to_dict() -> Dictionary: @@ -3976,11 +4281,16 @@ class InitConnectionConfig: dict["enableBillingProgramAndroid"] = BILLING_PROGRAM_ANDROID_VALUES[enable_billing_program_android] else: dict["enableBillingProgramAndroid"] = enable_billing_program_android + if billing_choice_screen_type_android != null: + if BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES.has(billing_choice_screen_type_android): + dict["billingChoiceScreenTypeAndroid"] = BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES[billing_choice_screen_type_android] + else: + dict["billingChoiceScreenTypeAndroid"] = billing_choice_screen_type_android return dict -## Parameters for launching an external link (Android) Used with launchExternalLink to initiate external offer or app install flows Available in Google Play Billing Library 8.2.0+ +## Parameters for launching an external link (Android) Used with launchExternalLink to initiate external offer, app install, or developer-rendered Billing Choice flows Available in Google Play Billing Library 8.2.0+ class LaunchExternalLinkParamsAndroid: - ## The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + ## The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) var billing_program: BillingProgramAndroid ## The external link launch mode var launch_mode: ExternalLinkLaunchModeAndroid @@ -3988,6 +4298,8 @@ class LaunchExternalLinkParamsAndroid: var link_type: ExternalLinkTypeAndroid ## The URI where the content will be accessed from var link_uri: String = "" + ## External transaction token for a developer-rendered Billing Choice external-link + var external_transaction_token: Variant = null static func from_dict(data: Dictionary) -> LaunchExternalLinkParamsAndroid: var obj = LaunchExternalLinkParamsAndroid.new() @@ -4011,6 +4323,8 @@ class LaunchExternalLinkParamsAndroid: obj.link_type = enum_str if data.has("linkUri") and data["linkUri"] != null: obj.link_uri = data["linkUri"] + if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: + obj.external_transaction_token = data["externalTransactionToken"] return obj func to_dict() -> Dictionary: @@ -4032,11 +4346,13 @@ class LaunchExternalLinkParamsAndroid: dict["linkType"] = link_type if link_uri != null: dict["linkUri"] = link_uri + if external_transaction_token != null: + dict["externalTransactionToken"] = external_transaction_token return dict class ProductRequest: var skus: Array[String] = [] - var type: ProductQueryType + var type: ProductQueryType = ProductQueryType.IN_APP static func from_dict(data: Dictionary) -> ProductRequest: var obj = ProductRequest.new() @@ -4093,6 +4409,7 @@ class PurchaseInput: var id: String = "" var product_id: String = "" var ids: Array[String] = [] + ## Unix timestamp in milliseconds since January 1, 1970 UTC. var transaction_date: float = 0.0 var purchase_token: Variant = null ## Store where purchase was made @@ -4232,7 +4549,7 @@ class RequestPurchaseAndroidProps: var is_offer_personalized: Variant = null ## Offer token for one-time purchase discounts (7.0+). var offer_token: Variant = null - ## Developer billing option parameters for external payments flow (8.3.0+). + ## Developer billing option parameters for external payments and Billing Choice. var developer_billing_option: DeveloperBillingOptionParamsAndroid static func from_dict(data: Dictionary) -> RequestPurchaseAndroidProps: @@ -4336,7 +4653,7 @@ class RequestPurchaseProps: ## Per-platform subscription request props var request_subscription: RequestSubscriptionPropsByPlatforms ## Explicit purchase type hint (defaults to in-app) - var type: ProductQueryType + var type: ProductQueryType = ProductQueryType.IN_APP ## @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. var use_alternative_billing: Variant = null @@ -4453,13 +4770,15 @@ class RequestSubscriptionAndroidProps: var is_offer_personalized: Variant = null ## Purchase token for upgrades/downgrades var purchase_token: Variant = null + ## Original external transaction ID for replacing a subscription that was + var original_external_transaction_id: Variant = null ## Replacement mode for subscription changes var replacement_mode: Variant = null ## Subscription offers var subscription_offers: Array[AndroidSubscriptionOfferInput] = [] ## Product-level replacement parameters (8.1.0+) var subscription_product_replacement_params: SubscriptionProductReplacementParamsAndroid - ## Developer billing option parameters for external payments flow (8.3.0+). + ## Developer billing option parameters for external payments and Billing Choice. var developer_billing_option: DeveloperBillingOptionParamsAndroid static func from_dict(data: Dictionary) -> RequestSubscriptionAndroidProps: @@ -4479,6 +4798,8 @@ class RequestSubscriptionAndroidProps: obj.is_offer_personalized = data["isOfferPersonalized"] if data.has("purchaseToken") and data["purchaseToken"] != null: obj.purchase_token = data["purchaseToken"] + if data.has("originalExternalTransactionId") and data["originalExternalTransactionId"] != null: + obj.original_external_transaction_id = data["originalExternalTransactionId"] if data.has("replacementMode") and data["replacementMode"] != null: obj.replacement_mode = data["replacementMode"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: @@ -4514,6 +4835,8 @@ class RequestSubscriptionAndroidProps: dict["isOfferPersonalized"] = is_offer_personalized if purchase_token != null: dict["purchaseToken"] = purchase_token + if original_external_transaction_id != null: + dict["originalExternalTransactionId"] = original_external_transaction_id if replacement_mode != null: dict["replacementMode"] = replacement_mode if subscription_offers != null: @@ -5023,12 +5346,25 @@ const ALTERNATIVE_BILLING_MODE_ANDROID_VALUES = { AlternativeBillingModeAndroid.ALTERNATIVE_ONLY: "alternative-only" } +const BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_VALUES = { + BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE: "rectangular-four-by-one", + BillingChoiceImageLayoutAndroid.RECTANGULAR_THREE_BY_ONE: "rectangular-three-by-one", + BillingChoiceImageLayoutAndroid.RECTANGULAR_TWO_BY_TWO: "rectangular-two-by-two" +} + +const BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES = { + BillingChoiceScreenTypeAndroid.UNSPECIFIED: "unspecified", + BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED: "developer-rendered", + BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED: "google-rendered" +} + const BILLING_PROGRAM_ANDROID_VALUES = { BillingProgramAndroid.UNSPECIFIED: "unspecified", BillingProgramAndroid.USER_CHOICE_BILLING: "user-choice-billing", BillingProgramAndroid.EXTERNAL_CONTENT_LINK: "external-content-link", BillingProgramAndroid.EXTERNAL_OFFER: "external-offer", - BillingProgramAndroid.EXTERNAL_PAYMENTS: "external-payments" + BillingProgramAndroid.EXTERNAL_PAYMENTS: "external-payments", + BillingProgramAndroid.BILLING_CHOICE: "billing-choice" } const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_VALUES = { @@ -5037,6 +5373,12 @@ const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_VALUES = { DeveloperBillingLaunchModeAndroid.CALLER_WILL_LAUNCH_LINK: "caller-will-launch-link" } +const DEVELOPER_BILLING_TYPE_ANDROID_VALUES = { + DeveloperBillingTypeAndroid.DEVELOPER_BILLING_TYPE_UNSPECIFIED: "developer-billing-type-unspecified", + DeveloperBillingTypeAndroid.IN_APP: "in-app", + DeveloperBillingTypeAndroid.EXTERNAL_LINK: "external-link" +} + const DISCOUNT_OFFER_TYPE_VALUES = { DiscountOfferType.INTRODUCTORY: "introductory", DiscountOfferType.PROMOTIONAL: "promotional", @@ -5145,6 +5487,16 @@ const IAP_STORE_VALUES = { IapStore.AMAZON: "amazon" } +const IN_APP_MESSAGE_CATEGORY_ANDROID_VALUES = { + InAppMessageCategoryAndroid.UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID: "unknown-in-app-message-category-id", + InAppMessageCategoryAndroid.TRANSACTIONAL: "transactional" +} + +const IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_VALUES = { + InAppMessageResponseCodeAndroid.NO_ACTION_NEEDED: "no-action-needed", + InAppMessageResponseCodeAndroid.SUBSCRIPTION_STATUS_UPDATED: "subscription-status-updated" +} + const PAYMENT_MODE_VALUES = { PaymentMode.FREE_TRIAL: "free-trial", PaymentMode.PAY_AS_YOU_GO: "pay-as-you-go", @@ -5299,12 +5651,25 @@ const ALTERNATIVE_BILLING_MODE_ANDROID_FROM_STRING = { "alternative-only": AlternativeBillingModeAndroid.ALTERNATIVE_ONLY } +const BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_FROM_STRING = { + "rectangular-four-by-one": BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE, + "rectangular-three-by-one": BillingChoiceImageLayoutAndroid.RECTANGULAR_THREE_BY_ONE, + "rectangular-two-by-two": BillingChoiceImageLayoutAndroid.RECTANGULAR_TWO_BY_TWO +} + +const BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING = { + "unspecified": BillingChoiceScreenTypeAndroid.UNSPECIFIED, + "developer-rendered": BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED, + "google-rendered": BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED +} + const BILLING_PROGRAM_ANDROID_FROM_STRING = { "unspecified": BillingProgramAndroid.UNSPECIFIED, "user-choice-billing": BillingProgramAndroid.USER_CHOICE_BILLING, "external-content-link": BillingProgramAndroid.EXTERNAL_CONTENT_LINK, "external-offer": BillingProgramAndroid.EXTERNAL_OFFER, - "external-payments": BillingProgramAndroid.EXTERNAL_PAYMENTS + "external-payments": BillingProgramAndroid.EXTERNAL_PAYMENTS, + "billing-choice": BillingProgramAndroid.BILLING_CHOICE } const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_FROM_STRING = { @@ -5313,6 +5678,12 @@ const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_FROM_STRING = { "caller-will-launch-link": DeveloperBillingLaunchModeAndroid.CALLER_WILL_LAUNCH_LINK } +const DEVELOPER_BILLING_TYPE_ANDROID_FROM_STRING = { + "developer-billing-type-unspecified": DeveloperBillingTypeAndroid.DEVELOPER_BILLING_TYPE_UNSPECIFIED, + "in-app": DeveloperBillingTypeAndroid.IN_APP, + "external-link": DeveloperBillingTypeAndroid.EXTERNAL_LINK +} + const DISCOUNT_OFFER_TYPE_FROM_STRING = { "introductory": DiscountOfferType.INTRODUCTORY, "promotional": DiscountOfferType.PROMOTIONAL, @@ -5421,6 +5792,16 @@ const IAP_STORE_FROM_STRING = { "amazon": IapStore.AMAZON } +const IN_APP_MESSAGE_CATEGORY_ANDROID_FROM_STRING = { + "unknown-in-app-message-category-id": InAppMessageCategoryAndroid.UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID, + "transactional": InAppMessageCategoryAndroid.TRANSACTIONAL +} + +const IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_FROM_STRING = { + "no-action-needed": InAppMessageResponseCodeAndroid.NO_ACTION_NEEDED, + "subscription-status-updated": InAppMessageResponseCodeAndroid.SUBSCRIPTION_STATUS_UPDATED +} + const PAYMENT_MODE_FROM_STRING = { "free-trial": PaymentMode.FREE_TRIAL, "pay-as-you-go": PaymentMode.PAY_AS_YOU_GO, @@ -5917,6 +6298,26 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false + ## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + class getBillingChoiceInfoAndroidField: + const name = "getBillingChoiceInfoAndroid" + const snake_name = "get_billing_choice_info_android" + class Args: + var params: GetBillingChoiceInfoParamsAndroid + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("params") and data["params"] != null: + obj.params = data["params"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["params"] = params + return dict + const return_type = "BillingChoiceInfoAndroid" + const is_array = false + # ============================================================================ # Mutation Types @@ -6315,6 +6716,7 @@ class Mutation: const snake_name = "create_billing_program_reporting_details_android" class Args: var program: BillingProgramAndroid + var developer_billing_type: DeveloperBillingTypeAndroid static func from_dict(data: Dictionary) -> Args: var obj = Args.new() @@ -6324,6 +6726,12 @@ class Mutation: obj.program = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] else: obj.program = enum_str + if data.has("developerBillingType") and data["developerBillingType"] != null: + var enum_str = data["developerBillingType"] + if enum_str is String and DEVELOPER_BILLING_TYPE_ANDROID_FROM_STRING.has(enum_str): + obj.developer_billing_type = DEVELOPER_BILLING_TYPE_ANDROID_FROM_STRING[enum_str] + else: + obj.developer_billing_type = enum_str return obj func to_dict() -> Dictionary: @@ -6332,11 +6740,15 @@ class Mutation: dict["program"] = BILLING_PROGRAM_ANDROID_VALUES[program] else: dict["program"] = program + if DEVELOPER_BILLING_TYPE_ANDROID_VALUES.has(developer_billing_type): + dict["developerBillingType"] = DEVELOPER_BILLING_TYPE_ANDROID_VALUES[developer_billing_type] + else: + dict["developerBillingType"] = developer_billing_type return dict const return_type = "BillingProgramReportingDetailsAndroid" const is_array = false - ## Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + ## Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), class launchExternalLinkAndroidField: const name = "launchExternalLinkAndroid" const snake_name = "launch_external_link_android" @@ -6356,6 +6768,46 @@ class Mutation: const return_type = "Boolean" const is_array = false + ## Show Google's mandatory information dialog before a developer-rendered, + class showBillingProgramInformationDialogAndroidField: + const name = "showBillingProgramInformationDialogAndroid" + const snake_name = "show_billing_program_information_dialog_android" + class Args: + var params: BillingProgramInformationDialogParamsAndroid + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("params") and data["params"] != null: + obj.params = data["params"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["params"] = params + return dict + const return_type = "BillingResultAndroid" + const is_array = false + + ## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + class showInAppMessagesAndroidField: + const name = "showInAppMessagesAndroid" + const snake_name = "show_in_app_messages_android" + class Args: + var params: InAppMessageParamsAndroid + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("params") and data["params"] != null: + obj.params = data["params"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["params"] = params + return dict + const return_type = "InAppMessageResultAndroid" + const is_array = false + # ============================================================================ # API Wrapper Functions @@ -6484,6 +6936,16 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args +## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. +static func get_billing_choice_info_android_args(params: GetBillingChoiceInfoParamsAndroid) -> Dictionary: + var args = {} + if params != null: + if params.has_method("to_dict"): + args["params"] = params.to_dict() + else: + args["params"] = params + return args + # Mutation API helpers ## Initialize the store connection. Call before any IAP API. @@ -6638,12 +7100,13 @@ static func is_billing_program_available_android_args(program: BillingProgramAnd return args ## Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). -static func create_billing_program_reporting_details_android_args(program: BillingProgramAndroid) -> Dictionary: +static func create_billing_program_reporting_details_android_args(program: BillingProgramAndroid, developer_billing_type: DeveloperBillingTypeAndroid) -> Dictionary: var args = {} args["program"] = program + args["developerBillingType"] = developer_billing_type return args -## Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). +## Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), static func launch_external_link_android_args(params: LaunchExternalLinkParamsAndroid) -> Dictionary: var args = {} if params != null: @@ -6652,3 +7115,23 @@ static func launch_external_link_android_args(params: LaunchExternalLinkParamsAn else: args["params"] = params return args + +## Show Google's mandatory information dialog before a developer-rendered, +static func show_billing_program_information_dialog_android_args(params: BillingProgramInformationDialogParamsAndroid) -> Dictionary: + var args = {} + if params != null: + if params.has_method("to_dict"): + args["params"] = params.to_dict() + else: + args["params"] = params + return args + +## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. +static func show_in_app_messages_android_args(params: InAppMessageParamsAndroid) -> Dictionary: + var args = {} + if params != null: + if params.has_method("to_dict"): + args["params"] = params.to_dict() + else: + args["params"] = params + return args diff --git a/libraries/godot-iap/android/build.gradle.kts b/libraries/godot-iap/android/build.gradle.kts index af9ab10b1..88f7f9620 100644 --- a/libraries/godot-iap/android/build.gradle.kts +++ b/libraries/godot-iap/android/build.gradle.kts @@ -54,6 +54,7 @@ android { defaultConfig { minSdk = googleMinSdk + missingDimensionStrategy("platform", "play") manifestPlaceholders["godotPluginName"] = pluginName manifestPlaceholders["godotPluginPackageName"] = pluginPackageName diff --git a/libraries/godot-iap/android/settings.gradle.kts b/libraries/godot-iap/android/settings.gradle.kts index 836bcfc58..2c0df53eb 100644 --- a/libraries/godot-iap/android/settings.gradle.kts +++ b/libraries/godot-iap/android/settings.gradle.kts @@ -35,11 +35,25 @@ pluginManagement { "org.jetbrains.kotlin.android", "kotlinVersion", ) + id("org.jetbrains.kotlin.plugin.compose") version googlePluginVersion( + "org.jetbrains.kotlin.plugin.compose", + "kotlinVersion", + ) + id("com.vanniktech.maven.publish") version googlePluginVersion( + "com.vanniktech.maven.publish", + "vanniktechMavenPublishVersion", + ) } } rootProject.name = "godot-iap" +val localOpenIapProject = file("../../../packages/google/openiap") +if (localOpenIapProject.exists()) { + include(":openiap") + project(":openiap").projectDir = localOpenIapProject +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { 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 170d7c0e5..770f666f7 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 @@ -5,7 +5,9 @@ import dev.hyo.openiap.OpenIapModule import dev.hyo.openiap.store.OpenIapStore import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -18,6 +20,7 @@ import org.godotengine.godot.plugin.UsedByGodot import org.json.JSONArray import org.json.JSONObject import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgram +import dev.hyo.openiap.DeveloperBillingTypeAndroid as OpenIapDeveloperBillingType import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunchMode import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams @@ -53,6 +56,14 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { emitSignal("subscription_billing_issue", JSONObject(sanitized).toString()) } + private val userChoiceBillingListener = OpenIapUserChoiceBillingListener { details -> + emitSignal("user_choice_billing", JSONObject(details.toJson()).toString()) + } + + private val developerProvidedBillingListener = OpenIapDeveloperProvidedBillingListener { details -> + emitSignal("developer_provided_billing", JSONObject(details.toJson()).toString()) + } + override fun getPluginName(): String = "GodotIap" override fun getPluginSignals(): Set { @@ -73,7 +84,22 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { // ========================================== @UsedByGodot - fun initConnection(): Boolean { + fun initConnection(): Boolean = initConnectionInternal(null) + + @UsedByGodot + fun initConnectionWithConfig(configJson: String): Boolean { + val config = try { + InitConnectionConfig.fromJson( + GodotIapHelper.jsonObjectToMap(JSONObject(configJson)) + ) + } catch (e: Exception) { + GodotIapLog.failure("initConnectionWithConfig", e) + return false + } + return initConnectionInternal(config) + } + + private fun initConnectionInternal(config: InitConnectionConfig?): Boolean { GodotIapLog.debug("initConnection called") val activity = activity ?: run { @@ -88,9 +114,11 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { store = OpenIapStore(openIap) store.addPurchaseUpdateListener(purchaseUpdateListener) store.addPurchaseErrorListener(purchaseErrorListener) + store.addUserChoiceBillingListener(userChoiceBillingListener) + store.addDeveloperProvidedBillingListener(developerProvidedBillingListener) openIap.addSubscriptionBillingIssueListener(subscriptionBillingIssueListener) - val result = store.initConnection() + val result = store.initConnection(config) isInitialized = result if (result) { @@ -116,6 +144,8 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { try { store.removePurchaseUpdateListener(purchaseUpdateListener) store.removePurchaseErrorListener(purchaseErrorListener) + store.removeUserChoiceBillingListener(userChoiceBillingListener) + store.removeDeveloperProvidedBillingListener(developerProvidedBillingListener) openIap.removeSubscriptionBillingIssueListener(subscriptionBillingIssueListener) val result = store.endConnection() @@ -243,6 +273,8 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { isOfferPersonalized = params.isOfferPersonalized, obfuscatedAccountId = params.obfuscatedAccountId, obfuscatedProfileId = params.obfuscatedProfileId, + developerBillingOption = params.developerBillingOption, + originalExternalTransactionId = params.originalExternalTransactionId, purchaseToken = params.purchaseToken, replacementMode = params.replacementMode, skus = params.skus, @@ -251,7 +283,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Subscription( - RequestSubscriptionPropsByPlatforms(android = android) + RequestSubscriptionPropsByPlatforms(google = android) ), type = ProductQueryType.Subs ) @@ -261,12 +293,13 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { isOfferPersonalized = params.isOfferPersonalized, obfuscatedAccountId = params.obfuscatedAccountId, obfuscatedProfileId = params.obfuscatedProfileId, + developerBillingOption = params.developerBillingOption, offerToken = params.offerTokenArr.firstOrNull(), skus = params.skus ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Purchase( - RequestPurchasePropsByPlatforms(android = android) + RequestPurchasePropsByPlatforms(google = android) ), type = ProductQueryType.InApp ) @@ -677,7 +710,9 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { JSONObject().apply { put("success", true) put("isAvailable", result.isAvailable) - put("billingProgram", billingProgram) + put("billingProgram", result.billingProgram.toJson()) + put("choiceScreenType", result.choiceScreenType?.toJson()) + put("isExternalLinkAvailable", result.isExternalLinkAvailable) }.toString() } catch (e: Exception) { GodotIapLog.failure("isBillingProgramAvailableAndroid", e) @@ -690,6 +725,44 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } } + @UsedByGodot + fun getBillingChoiceInfoAndroid(paramsJson: String): String { + GodotIapLog.payload("getBillingChoiceInfoAndroid", paramsJson) + + if (!isInitialized) { + return JSONObject().apply { + put("success", false) + put("error", "Not initialized") + }.toString() + } + + return runBlocking { + try { + val json = JSONObject(paramsJson) + val params = GetBillingChoiceInfoParamsAndroid( + billingProgram = mapBillingProgram(json.optString("billingProgram", "billing-choice")), + playBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.fromJson( + json.optString("playBillingChoiceImageLayout", "rectangular-four-by-one") + ), + userLocale = json.optString("userLocale", "").takeIf { it.isNotBlank() } + ) + val result = store.getBillingChoiceInfo(params) + GodotIapLog.result("getBillingChoiceInfoAndroid", "image url received") + JSONObject().apply { + put("success", true) + put("playBillingChoiceImageUrl", result.playBillingChoiceImageUrl) + put("playBillingLoyaltyInfo", result.playBillingLoyaltyInfo) + }.toString() + } catch (e: Exception) { + GodotIapLog.failure("getBillingChoiceInfoAndroid", e) + JSONObject().apply { + put("success", false) + put("error", e.message) + }.toString() + } + } + } + @UsedByGodot fun launchExternalLinkAndroid(paramsJson: String): String { GodotIapLog.payload("launchExternalLinkAndroid", paramsJson) @@ -715,9 +788,12 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { val launchMode = json.optString("launchMode", "unspecified") val linkType = json.optString("linkType", "unspecified") val linkUri = json.getString("linkUri") + val externalTransactionToken = json.optString("externalTransactionToken", "") + .takeIf { it.isNotBlank() } val params = OpenIapLaunchExternalLinkParams( billingProgram = mapBillingProgram(billingProgram), + externalTransactionToken = externalTransactionToken, launchMode = mapExternalLinkLaunchMode(launchMode), linkType = mapExternalLinkType(linkType), linkUri = linkUri @@ -741,6 +817,30 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { @UsedByGodot fun createBillingProgramReportingDetailsAndroid(billingProgram: String): String { + return createBillingProgramReportingDetailsAndroidInternal(billingProgram, null) + } + + @UsedByGodot + fun createBillingProgramReportingDetailsAndroidWithType(paramsJson: String): String { + GodotIapLog.payload("createBillingProgramReportingDetailsAndroidWithType", paramsJson) + return try { + val json = JSONObject(paramsJson) + createBillingProgramReportingDetailsAndroidInternal( + json.optString("billingProgram", "unspecified"), + json.optString("developerBillingType", "").takeIf { it.isNotBlank() } + ) + } catch (e: Exception) { + JSONObject().apply { + put("success", false) + put("error", e.message) + }.toString() + } + } + + private fun createBillingProgramReportingDetailsAndroidInternal( + billingProgram: String, + developerBillingType: String? + ): String { GodotIapLog.payload("createBillingProgramReportingDetailsAndroid", billingProgram) if (!isInitialized) { @@ -753,7 +853,10 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { return runBlocking { try { val program = mapBillingProgram(billingProgram) - val result = store.createBillingProgramReportingDetails(program) + val result = store.createBillingProgramReportingDetails( + program, + developerBillingType?.let { OpenIapDeveloperBillingType.fromJson(it) } + ) GodotIapLog.result("createBillingProgramReportingDetailsAndroid", "token generated") JSONObject().apply { put("success", true) @@ -770,6 +873,104 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } } + @UsedByGodot + fun showBillingProgramInformationDialogAndroid(paramsJson: String): String { + GodotIapLog.payload("showBillingProgramInformationDialogAndroid", paramsJson) + + if (!isInitialized) { + return JSONObject().apply { + put("success", false) + put("error", "Not initialized") + }.toString() + } + + val activity = activity ?: run { + return JSONObject().apply { + put("success", false) + put("error", "Activity not available") + }.toString() + } + + return runBlocking { + try { + val json = JSONObject(paramsJson) + val token = json.optString("externalTransactionToken", "") + if (token.isBlank()) { + return@runBlocking JSONObject().apply { + put("success", false) + put("error", "externalTransactionToken is required") + }.toString() + } + val result = store.showBillingProgramInformationDialog( + activity, + BillingProgramInformationDialogParamsAndroid( + billingProgram = mapBillingProgram(json.optString("billingProgram", "billing-choice")), + externalTransactionToken = token + ) + ) + JSONObject().apply { + put("success", true) + put("responseCode", result.responseCode) + put("debugMessage", result.debugMessage) + put("subResponseCode", result.subResponseCode?.toJson()) + }.toString() + } catch (e: Exception) { + GodotIapLog.failure("showBillingProgramInformationDialogAndroid", e) + JSONObject().apply { + put("success", false) + put("error", e.message) + }.toString() + } + } + } + + @UsedByGodot + fun showInAppMessagesAndroid(paramsJson: String): String { + GodotIapLog.payload("showInAppMessagesAndroid", paramsJson) + + if (!isInitialized) { + return JSONObject().apply { + put("success", false) + put("error", "Not initialized") + }.toString() + } + + val activity = activity ?: run { + return JSONObject().apply { + put("success", false) + put("error", "Activity not available") + }.toString() + } + + return runBlocking { + try { + val json = JSONObject(paramsJson.ifBlank { "{}" }) + val categoriesJson = json.optJSONArray("categories") + val categories = categoriesJson?.let { array -> + (0 until array.length()).mapNotNull { index -> + array.optString(index, "").takeIf { it.isNotBlank() } + ?.let { InAppMessageCategoryAndroid.fromJson(it) } + } + } + val result = store.showInAppMessages( + activity, + InAppMessageParamsAndroid(categories = categories) + ) + JSONObject().apply { + put("success", true) + put("responseCode", result.responseCode.toJson()) + put("purchaseToken", result.purchaseToken) + }.toString() + } catch (e: Exception) { + GodotIapLog.failure("showInAppMessagesAndroid", e) + JSONObject().apply { + put("success", false) + put("error", e.message) + }.toString() + } + } + } + // ========================================== // Storefront & Deep Link // ========================================== @@ -1008,6 +1209,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { "external-content-link" -> OpenIapBillingProgram.ExternalContentLink "external-payments" -> OpenIapBillingProgram.ExternalPayments "user-choice-billing" -> OpenIapBillingProgram.UserChoiceBilling + "billing-choice" -> OpenIapBillingProgram.BillingChoice else -> OpenIapBillingProgram.Unspecified } diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapHelper.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapHelper.kt index 850e93141..0a9eb160c 100644 --- a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapHelper.kt +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapHelper.kt @@ -1,9 +1,11 @@ package dev.hyo.godotiap import dev.hyo.openiap.AndroidSubscriptionOfferInput +import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid import dev.hyo.openiap.SubscriptionReplacementModeAndroid +import org.json.JSONArray import org.json.JSONObject import java.util.Locale @@ -13,6 +15,21 @@ import java.util.Locale */ internal object GodotIapHelper { + fun jsonObjectToMap(json: JSONObject): Map = buildMap { + val keys = json.keys() + while (keys.hasNext()) { + val key = keys.next() + put(key, jsonValue(json.opt(key))) + } + } + + private fun jsonValue(value: Any?): Any? = when (value) { + null, JSONObject.NULL -> null + is JSONObject -> jsonObjectToMap(value) + is JSONArray -> List(value.length()) { index -> jsonValue(value.opt(index)) } + else -> value + } + /** * Helper extension to get nullable string from JSONObject. * Returns null if the key doesn't exist or the value is empty. @@ -90,6 +107,7 @@ internal object GodotIapHelper { val isOfferPersonalized = json.optBoolean("isOfferPersonalized", false) val purchaseToken = json.optStringOrNull("purchaseTokenAndroid") ?: json.optStringOrNull("purchaseToken") + val originalExternalTransactionId = json.optStringOrNull("originalExternalTransactionId") val replacementMode = when { json.has("replacementModeAndroid") -> json.optInt("replacementModeAndroid") json.has("replacementMode") -> json.optInt("replacementMode") @@ -112,6 +130,10 @@ internal object GodotIapHelper { } else null } else null + val developerBillingOption = json.optJSONObject("developerBillingOption")?.let { + DeveloperBillingOptionParamsAndroid.fromJson(jsonObjectToMap(it)) + } + // Parse offer token array val offerTokenArr = mutableListOf() val offerTokenArray = json.optJSONArray("offerTokenArr") @@ -160,6 +182,8 @@ internal object GodotIapHelper { isOfferPersonalized = isOfferPersonalized, offerTokenArr = offerTokenArr, subscriptionOffers = subscriptionOffers, + developerBillingOption = developerBillingOption, + originalExternalTransactionId = originalExternalTransactionId, purchaseToken = purchaseToken, replacementMode = replacementMode, subscriptionProductReplacementParams = subscriptionProductReplacementParams @@ -194,6 +218,8 @@ internal object GodotIapHelper { val isOfferPersonalized: Boolean, val offerTokenArr: List, val subscriptionOffers: List, + val developerBillingOption: DeveloperBillingOptionParamsAndroid?, + val originalExternalTransactionId: String?, val purchaseToken: String?, val replacementMode: Int?, val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index e8c62e44e..815103d43 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -483,6 +483,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func syncIOS() -> String { GodotIapLog.payload("Syncing with App Store", payload: nil) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -490,6 +491,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { let result = try await self.openIap.syncIOS() await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("syncIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(result) self.productsFetched.emit(dict) } @@ -498,6 +501,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.debug("[GodotIap] syncIOS error: \(error.localizedDescription)") await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("syncIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) @@ -505,12 +510,13 @@ public class GodotIap: RefCounted, @unchecked Sendable { } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } @Callable public func clearTransactionIOS() -> String { GodotIapLog.payload("Clearing transactions", payload: nil) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -518,16 +524,26 @@ public class GodotIap: RefCounted, @unchecked Sendable { let result = try await self.openIap.clearTransactionIOS() await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("clearTransactionIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(result) self.productsFetched.emit(dict) } GodotIapLog.debug("[GodotIap] Clear transactions completed: \(result)") } catch { GodotIapLog.debug("[GodotIap] clearTransactionIOS error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["method"] = Variant("clearTransactionIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } @Callable @@ -587,6 +603,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func presentCodeRedemptionSheetIOS() -> String { GodotIapLog.payload("Presenting code redemption sheet", payload: nil) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -594,15 +611,25 @@ public class GodotIap: RefCounted, @unchecked Sendable { let result = try await self.openIap.presentCodeRedemptionSheetIOS() await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("presentCodeRedemptionSheetIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(result) self.productsFetched.emit(dict) } } catch { GodotIapLog.debug("[GodotIap] presentCodeRedemptionSheetIOS error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["method"] = Variant("presentCodeRedemptionSheetIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } @Callable @@ -635,6 +662,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func beginRefundRequestIOS(sku: String) -> String { GodotIapLog.debug("[GodotIap] Beginning refund request for: \(sku)") + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -642,6 +670,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { let result = try await self.openIap.beginRefundRequestIOS(sku: sku) await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("beginRefundRequestIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) dict["status"] = Variant(result ?? "unknown") self.productsFetched.emit(dict) @@ -650,6 +680,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.debug("[GodotIap] beginRefundRequestIOS error: \(error.localizedDescription)") await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("beginRefundRequestIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) @@ -657,7 +689,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } @Callable @@ -895,6 +927,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func requestPurchaseOnPromotedProductIOS() -> String { GodotIapLog.payload("Requesting purchase on promoted product", payload: nil) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -902,15 +935,25 @@ public class GodotIap: RefCounted, @unchecked Sendable { let result = try await self.openIap.requestPurchaseOnPromotedProductIOS() await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("requestPurchaseOnPromotedProductIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(result) self.productsFetched.emit(dict) } } catch { GodotIapLog.debug("[GodotIap] requestPurchaseOnPromotedProductIOS error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["method"] = Variant("requestPurchaseOnPromotedProductIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } @Callable @@ -985,6 +1028,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func deepLinkToSubscriptions(optionsJson: String) -> String { GodotIapLog.payload("Deep linking to subscriptions", payload: nil) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -1002,15 +1046,25 @@ public class GodotIap: RefCounted, @unchecked Sendable { try await self.openIap.deepLinkToSubscriptions(options) await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("deepLinkToSubscriptions") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) self.productsFetched.emit(dict) } } catch { GodotIapLog.debug("[GodotIap] deepLinkToSubscriptions error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["method"] = Variant("deepLinkToSubscriptions") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } // MARK: - Verification Methods diff --git a/libraries/godot-iap/scripts/install-hooks.sh b/libraries/godot-iap/scripts/install-hooks.sh index edc517cd6..f0260d1f8 100755 --- a/libraries/godot-iap/scripts/install-hooks.sh +++ b/libraries/godot-iap/scripts/install-hooks.sh @@ -3,13 +3,29 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -HOOKS_DIR="$PROJECT_ROOT/.git/hooks" + +REPO_ROOT="$(git -C "$PROJECT_ROOT" rev-parse --show-toplevel 2>/dev/null)" || { + echo "Not inside a Git worktree; skipping hook installation." + exit 0 +} + +if [ "$REPO_ROOT" != "$PROJECT_ROOT" ]; then + echo "Using monorepo hooks from $REPO_ROOT; skipping library-local hook installation." + exit 0 +fi + +HOOKS_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-path hooks)" +case "$HOOKS_DIR" in + /*) ;; + *) HOOKS_DIR="$PROJECT_ROOT/$HOOKS_DIR" ;; +esac echo "Installing git hooks..." +mkdir -p "$HOOKS_DIR" # Create symbolic link for pre-commit if [ -f "$SCRIPT_DIR/pre-commit" ]; then - ln -sf "$SCRIPT_DIR/pre-commit" "$HOOKS_DIR/pre-commit" + ln -sf "$SCRIPT_DIR/pre-commit" "$HOOKS_DIR/pre-commit" || exit 1 echo "✓ pre-commit hook installed" fi diff --git a/libraries/kmp-iap/README.md b/libraries/kmp-iap/README.md index cb3b8b68d..716694c8e 100644 --- a/libraries/kmp-iap/README.md +++ b/libraries/kmp-iap/README.md @@ -134,7 +134,7 @@ kmp-iap conforms to the **[OpenIAP specification](https://openiap.dev)** — an - **Shared specification** — Common types, error codes, and purchase flows across all platforms - **Generated type-safe bindings** — Swift, Kotlin, Dart, and GDScript from a single GraphQL schema -- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 8.x) +- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 9.1.0) - **Verification profiles** — Standardized receipt validation and purchase verification patterns Other libraries built on OpenIAP: [react-native-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap) · [expo-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap) · [flutter_inapp_purchase](https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase) · [godot-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/godot-iap) diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt index 1ab2480dd..54fa30e04 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import dev.hyo.martie.theme.AppColors import dev.hyo.martie.utils.swipeToBack +import io.github.hyochan.kmpiap.PurchaseException import io.github.hyochan.kmpiap.kmpIapInstance import io.github.hyochan.kmpiap.openiap.* import io.github.hyochan.kmpiap.requestPurchase @@ -79,7 +80,14 @@ fun AlternativeBillingScreen(navController: NavController) { try { val config = if (currentPlatform == "Android") { - InitConnectionConfig(enableBillingProgramAndroid = billingProgram) + InitConnectionConfig( + enableBillingProgramAndroid = billingProgram, + billingChoiceScreenTypeAndroid = if (billingProgram == BillingProgramAndroid.BillingChoice) { + BillingChoiceScreenTypeAndroid.DeveloperRendered + } else { + null + } + ) } else null connected = kmpIapInstance.initConnection(config) @@ -115,7 +123,7 @@ fun AlternativeBillingScreen(navController: NavController) { lastPurchase = purchase isProcessing = false - val dateText = Instant.fromEpochSeconds(purchase.transactionDate.toLong()) + val dateText = Instant.fromEpochMilliseconds(purchase.transactionDate.toLong()) .toLocalDateTime(TimeZone.currentSystemDefault()) purchaseResult = """ @@ -145,7 +153,10 @@ fun AlternativeBillingScreen(navController: NavController) { launch { kmpIapInstance.purchaseErrorListener.collect { error -> isProcessing = false - purchaseResult = "❌ Purchase failed: ${error.message}" + purchaseResult = when (error.code) { + ErrorCode.UserCancelled -> "⚠️ Purchase cancelled by user" + else -> "❌ Purchase failed: ${error.message}" + } } } } @@ -171,7 +182,14 @@ fun AlternativeBillingScreen(navController: NavController) { // Reinitialize with new program val config = if (currentPlatform == "Android") { - InitConnectionConfig(enableBillingProgramAndroid = program) + InitConnectionConfig( + enableBillingProgramAndroid = program, + billingChoiceScreenTypeAndroid = if (program == BillingProgramAndroid.BillingChoice) { + BillingChoiceScreenTypeAndroid.DeveloperRendered + } else { + null + } + ) } else null connected = kmpIapInstance.initConnection(config) @@ -179,6 +197,7 @@ fun AlternativeBillingScreen(navController: NavController) { purchaseResult = "✅ Reconnected with ${ when (program) { BillingProgramAndroid.UserChoiceBilling -> "User Choice Billing" + BillingProgramAndroid.BillingChoice -> "Billing Choice" BillingProgramAndroid.ExternalOffer -> "External Offer" BillingProgramAndroid.ExternalPayments -> "External Payments (Japan)" BillingProgramAndroid.ExternalContentLink -> "External Content Link" @@ -326,6 +345,89 @@ fun AlternativeBillingScreen(navController: NavController) { } } + // Handle Android Billing Choice (9.1.0+) + fun handleAndroidBillingChoice(product: ProductCommon) { + scope.launch { + isProcessing = true + purchaseResult = "Checking Billing Choice availability..." + + try { + val availability = kmpIapInstance.isBillingProgramAvailableAndroid( + BillingProgramAndroid.BillingChoice + ) + + if (!availability.isAvailable) { + purchaseResult = "❌ Billing Choice not available for this user or product" + isProcessing = false + return@launch + } + + if (availability.choiceScreenType != BillingChoiceScreenTypeAndroid.DeveloperRendered) { + purchaseResult = "❌ This example is configured for a developer-rendered Billing Choice screen" + isProcessing = false + return@launch + } + + purchaseResult = "Fetching Billing Choice display metadata..." + val choiceInfo = kmpIapInstance.getBillingChoiceInfoAndroid( + GetBillingChoiceInfoParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.RectangularFourByOne + ) + ) + + purchaseResult = "Creating an in-app Billing Choice reporting token..." + val details = kmpIapInstance.createBillingProgramReportingDetailsAndroid( + BillingProgramAndroid.BillingChoice, + DeveloperBillingTypeAndroid.InApp + ) + + purchaseResult = "Showing Billing Choice information dialog..." + val dialogResult = kmpIapInstance.showBillingProgramInformationDialogAndroid( + BillingProgramInformationDialogParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice, + externalTransactionToken = details.externalTransactionToken + ) + ) + + purchaseResult = """ + ✅ Developer-rendered in-app Billing Choice is ready + + Product: ${product.id} + Billing type: ${DeveloperBillingTypeAndroid.InApp.rawValue} + Choice image: ${choiceInfo.playBillingChoiceImageUrl} + Token: ${details.externalTransactionToken} + Dialog response: ${dialogResult.responseCode} + + ⚠️ Render your choice UI now. If the user selects developer billing, complete the payment and report the token to Google Play within 24 hours. + """.trimIndent() + } catch (e: Exception) { + purchaseResult = "❌ Error: ${e.message}" + } finally { + isProcessing = false + } + } + } + + fun showAndroidBillingMessages() { + scope.launch { + isProcessing = true + purchaseResult = "Checking for Play billing messages..." + try { + val result = kmpIapInstance.showInAppMessagesAndroid( + InAppMessageParamsAndroid( + categories = listOf(InAppMessageCategoryAndroid.Transactional) + ) + ) + purchaseResult = "Play billing message result: ${result.responseCode.rawValue}" + } catch (e: Exception) { + purchaseResult = "❌ Error: ${e.message}" + } finally { + isProcessing = false + } + } + } + // Handle Android User Choice Billing (7.0+) fun handleAndroidUserChoiceBilling(product: ProductCommon) { scope.launch { @@ -351,6 +453,11 @@ fun AlternativeBillingScreen(navController: NavController) { ⚠️ Billing Library 7.0+ required """.trimIndent() + } catch (e: PurchaseException) { + if (e.error.code != ErrorCode.UserCancelled) { + purchaseResult = "❌ Error: ${e.message}" + } + isProcessing = false } catch (e: Exception) { purchaseResult = "❌ Error: ${e.message}" isProcessing = false @@ -365,6 +472,7 @@ fun AlternativeBillingScreen(navController: NavController) { } else if (currentPlatform == "Android") { when (billingProgram) { BillingProgramAndroid.UserChoiceBilling -> handleAndroidUserChoiceBilling(product) + BillingProgramAndroid.BillingChoice -> handleAndroidBillingChoice(product) BillingProgramAndroid.ExternalOffer, BillingProgramAndroid.ExternalPayments, BillingProgramAndroid.ExternalContentLink -> handleAndroidBillingPrograms(product) @@ -533,6 +641,7 @@ fun AlternativeBillingScreen(navController: NavController) { isProcessing -> "Processing..." currentPlatform == "iOS" -> "🛒 Buy (External URL)" billingProgram == BillingProgramAndroid.UserChoiceBilling -> "🛒 Buy (User Choice)" + billingProgram == BillingProgramAndroid.BillingChoice -> "🛒 Buy (Billing Choice)" billingProgram == BillingProgramAndroid.ExternalOffer -> "🛒 Buy (External Offer)" billingProgram == BillingProgramAndroid.ExternalPayments -> "🛒 Buy (External Payments)" billingProgram == BillingProgramAndroid.ExternalContentLink -> "🛒 Buy (External Link)" @@ -544,6 +653,17 @@ fun AlternativeBillingScreen(navController: NavController) { } } + if (currentPlatform == "Android") { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { showAndroidBillingMessages() }, + modifier = Modifier.fillMaxWidth().height(48.dp), + enabled = !isProcessing && connected + ) { + Text("Show Play billing message") + } + } + Spacer(modifier = Modifier.height(16.dp)) // Purchase Result @@ -588,6 +708,17 @@ fun AlternativeBillingScreen(navController: NavController) { } ) Spacer(modifier = Modifier.height(8.dp)) + ModeSelectorOption( + title = "Billing Choice (9.1.0+)", + description = "Use Google or developer-rendered billing choice screens.", + isSelected = billingProgram == BillingProgramAndroid.BillingChoice, + onClick = { + billingProgram = BillingProgramAndroid.BillingChoice + showModeSelector = false + reconnectWithProgram(BillingProgramAndroid.BillingChoice) + } + ) + Spacer(modifier = Modifier.height(8.dp)) ModeSelectorOption( title = "External Offer (8.2.0+)", description = "Launch an external offer and create reporting details.", @@ -683,6 +814,13 @@ private fun InfoCard(platform: String, billingProgram: BillingProgramAndroid) { • If Google Play: onPurchaseUpdated • If alternative: userChoiceBillingListener """.trimIndent() + BillingProgramAndroid.BillingChoice -> """ + • Billing Choice (9.1.0+) + • This example configures a developer-rendered screen + • Fetch Play choice metadata + • Request an IN_APP reporting token + • Show the required information dialog before your choice UI + """.trimIndent() BillingProgramAndroid.ExternalOffer -> """ • External Offer Mode (8.2.0+) • Users CANNOT use Google Play billing @@ -718,6 +856,11 @@ private fun InfoCard(platform: String, billingProgram: BillingProgramAndroid) { ⚠️ Requires approval from Google ⚠️ Must report tokens within 24 hours """.trimIndent() + BillingProgramAndroid.BillingChoice -> """ + ⚠️ Billing Library 9.1.0+ required + ⚠️ Requires Billing Choice enrollment + ⚠️ Availability depends on region and product + """.trimIndent() BillingProgramAndroid.ExternalOffer -> """ ⚠️ Billing Library 8.2.0+ required ⚠️ Requires approval from Google @@ -780,6 +923,7 @@ private fun ModeSelectorCard( Text( text = when (billingProgram) { BillingProgramAndroid.UserChoiceBilling -> "User Choice Billing (7.0+)" + BillingProgramAndroid.BillingChoice -> "Billing Choice (9.1.0+)" BillingProgramAndroid.ExternalOffer -> "External Offer (8.2.0+)" BillingProgramAndroid.ExternalPayments -> "External Payments (8.3.0+, Japan)" BillingProgramAndroid.ExternalContentLink -> "External Content Link (8.2.0+)" @@ -985,7 +1129,7 @@ private fun LastPurchaseCard(purchase: Purchase) { Column(modifier = Modifier.padding(16.dp)) { Text("Product: ${purchase.productId}", fontSize = 14.sp) Text("Transaction: ${purchase.id}", fontSize = 14.sp) - val dateText = Instant.fromEpochSeconds(purchase.transactionDate.toLong()) + val dateText = Instant.fromEpochMilliseconds(purchase.transactionDate.toLong()) .toLocalDateTime(TimeZone.currentSystemDefault()) .toString() Text("Date: $dateText", fontSize = 14.sp) 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 736412880..ff820f20e 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 @@ -625,7 +625,7 @@ fun PurchaseCard( color = AppColors.Secondary ) - val instant = kotlinx.datetime.Instant.fromEpochSeconds(purchase.transactionDate.toLong()) + val instant = kotlinx.datetime.Instant.fromEpochMilliseconds(purchase.transactionDate.toLong()) Text( text = "Date: $instant", fontSize = 12.sp, diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt index eb847d266..05d83ec42 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -21,6 +21,7 @@ import androidx.navigation.NavController import dev.hyo.martie.config.AppConfig import dev.hyo.martie.theme.AppColors import dev.hyo.martie.utils.swipeToBack +import io.github.hyochan.kmpiap.PurchaseException import io.github.hyochan.kmpiap.kmpIapInstance import io.github.hyochan.kmpiap.fetchProducts import io.github.hyochan.kmpiap.requestPurchase @@ -115,7 +116,7 @@ fun PurchaseFlowScreen(navController: NavController) { println(jsonString) println("=============================================\n") - val dateText = Instant.fromEpochSeconds(purchase.transactionDate.toLong()) + val dateText = Instant.fromEpochMilliseconds(purchase.transactionDate.toLong()) .toLocalDateTime(TimeZone.currentSystemDefault()) purchaseResult = """ ✅ Purchase successful (${purchase.platform}) @@ -494,6 +495,11 @@ fun PurchaseFlowScreen(navController: NavController) { } } // Purchase updates will be received through the Flow + } catch (e: PurchaseException) { + if (e.error.code != ErrorCode.UserCancelled) { + purchaseResult = "Purchase failed: ${e.message}" + } + isProcessing = false } catch (e: Exception) { purchaseResult = "Purchase failed: ${e.message}" isProcessing = false diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt index e9e821c44..e71c06d8b 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -22,6 +22,7 @@ import dev.hyo.martie.config.AppConfig import dev.hyo.martie.theme.AppColors import dev.hyo.martie.utils.swipeToBack import io.github.hyochan.kmpiap.KmpIAP +import io.github.hyochan.kmpiap.PurchaseException import io.github.hyochan.kmpiap.requestPurchase import io.github.hyochan.kmpiap.toPurchaseInput import io.github.hyochan.kmpiap.getCurrentPlatform @@ -106,7 +107,7 @@ fun SubscriptionFlowScreen(navController: NavController) { PurchaseState.Purchased -> { isProcessing = false - val dateText = Instant.fromEpochSeconds(purchase.transactionDate.toLong()) + val dateText = Instant.fromEpochMilliseconds(purchase.transactionDate.toLong()) .toLocalDateTime(TimeZone.currentSystemDefault()) purchaseResult = """ ✅ Subscription successful (${purchase.platform}) @@ -778,6 +779,11 @@ fun SubscriptionFlowScreen(navController: NavController) { } // Purchase updates will be received through the purchaseUpdatedListener // The UI will be updated automatically when the listener triggers + } catch (e: PurchaseException) { + if (e.error.code != ErrorCode.UserCancelled) { + purchaseResult = "Subscription failed: ${e.message}" + } + isProcessing = false } catch (e: Exception) { purchaseResult = "Subscription failed: ${e.message}" isProcessing = false diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts index a6cc54cf2..c2a096e65 100644 --- a/libraries/kmp-iap/library/build.gradle.kts +++ b/libraries/kmp-iap/library/build.gradle.kts @@ -339,16 +339,16 @@ android { } dependencies { - val playBillingVersion = "8.3.0" + val playBillingVersion = "9.1.0" add("playImplementation", "io.github.hyochan.openiap:openiap-google:$googleVersion") add("horizonImplementation", "io.github.hyochan.openiap:openiap-google-horizon:$googleVersion") add("amazonImplementation", "io.github.hyochan.openiap:openiap-google-amazon:$googleVersion") // androidMain still contains the Play Billing implementation. Amazon uses // the OpenIAP module delegate at runtime, but these symbols must be present // while compiling the shared Android source set for non-Play variants. - add("horizonCompileOnly", "com.android.billingclient:billing-ktx:$playBillingVersion") - add("amazonCompileOnly", "com.android.billingclient:billing-ktx:$playBillingVersion") - add("androidUnitTestImplementation", "com.android.billingclient:billing-ktx:$playBillingVersion") + add("horizonCompileOnly", "com.android.billingclient:billing:$playBillingVersion") + add("amazonCompileOnly", "com.android.billingclient:billing:$playBillingVersion") + add("androidUnitTestImplementation", "com.android.billingclient:billing:$playBillingVersion") } // Only configure publishing when we have signing credentials diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt index 61c9d0a40..1f46fc8ef 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt @@ -16,8 +16,12 @@ import io.github.hyochan.kmpiap.openiap.ActiveSubscription import io.github.hyochan.kmpiap.openiap.AppTransaction import io.github.hyochan.kmpiap.openiap.BillingProgramAndroid import io.github.hyochan.kmpiap.openiap.BillingProgramAvailabilityResultAndroid +import io.github.hyochan.kmpiap.openiap.BillingChoiceInfoAndroid +import io.github.hyochan.kmpiap.openiap.BillingProgramInformationDialogParamsAndroid import io.github.hyochan.kmpiap.openiap.BillingProgramReportingDetailsAndroid +import io.github.hyochan.kmpiap.openiap.BillingResultAndroid import io.github.hyochan.kmpiap.openiap.DeepLinkOptions +import io.github.hyochan.kmpiap.openiap.DeveloperBillingTypeAndroid import io.github.hyochan.kmpiap.openiap.DeveloperProvidedBillingDetailsAndroid import io.github.hyochan.kmpiap.openiap.ErrorCode import io.github.hyochan.kmpiap.openiap.ExternalPurchaseCustomLinkNoticeResultIOS @@ -31,6 +35,9 @@ import io.github.hyochan.kmpiap.openiap.FetchProductsResultAll import io.github.hyochan.kmpiap.openiap.FetchProductsResultProducts import io.github.hyochan.kmpiap.openiap.FetchProductsResultSubscriptions import io.github.hyochan.kmpiap.openiap.InitConnectionConfig +import io.github.hyochan.kmpiap.openiap.GetBillingChoiceInfoParamsAndroid +import io.github.hyochan.kmpiap.openiap.InAppMessageParamsAndroid +import io.github.hyochan.kmpiap.openiap.InAppMessageResultAndroid import io.github.hyochan.kmpiap.openiap.LaunchExternalLinkParamsAndroid import io.github.hyochan.kmpiap.openiap.Product import io.github.hyochan.kmpiap.openiap.ProductOrSubscription @@ -97,13 +104,15 @@ internal class AmazonInAppPurchaseAndroid( private var subscriptionIssueListener: OpenIapSubscriptionBillingIssueListener? = null override suspend fun initConnection(config: InitConnectionConfig?): Boolean = withContext(Dispatchers.Main) { - val ctx = ensureContext() - val openModule = module ?: buildOpenIapModule(ctx).also { created -> - module = created - registerListeners(created) + withMappedOpenIapError { + val ctx = ensureContext() + val openModule = module ?: buildOpenIapModule(ctx).also { created -> + module = created + registerListeners(created) + } + openModule.setActivity(currentActivity) + openModule.initConnection(config?.toOpenIap()) } - openModule.setActivity(currentActivity) - openModule.initConnection(config?.toOpenIap()) } override suspend fun endConnection(): Boolean = withContext(Dispatchers.IO) { @@ -119,42 +128,57 @@ internal class AmazonInAppPurchaseAndroid( } override suspend fun fetchProducts(params: ProductRequest): FetchProductsResult = - requireModule().fetchProducts(params.toOpenIap()).toKmp() + withMappedOpenIapError { requireModule().fetchProducts(params.toOpenIap()).toKmp() } override suspend fun requestPurchase(params: RequestPurchaseProps): RequestPurchaseResult? = - requireModule().requestPurchase(params.toOpenIap())?.toKmp() + withMappedOpenIapError { requireModule().requestPurchase(params.toOpenIap())?.toKmp() } override suspend fun getAvailablePurchases(options: PurchaseOptions?): List = - requireModule().getAvailablePurchases(options?.toOpenIap()).map { it.toKmp() } + withMappedOpenIapError { + requireModule().getAvailablePurchases(options?.toOpenIap()).map { it.toKmp() } + } override suspend fun getActiveSubscriptions(subscriptionIds: List?): List = - requireModule().queryHandlers.getActiveSubscriptions?.invoke(subscriptionIds) - ?.map { ActiveSubscription.fromJson(it.toJson()) } - ?: emptyList() + withMappedOpenIapError { + requireModule().queryHandlers.getActiveSubscriptions?.invoke(subscriptionIds) + ?.map { ActiveSubscription.fromJson(it.toJson()) } + ?: emptyList() + } override suspend fun hasActiveSubscriptions(subscriptionIds: List?): Boolean = - requireModule().queryHandlers.hasActiveSubscriptions?.invoke(subscriptionIds) ?: false + withMappedOpenIapError { + requireModule().queryHandlers.hasActiveSubscriptions?.invoke(subscriptionIds) ?: false + } override suspend fun restorePurchases() { - requireModule().mutationHandlers.restorePurchases?.invoke() + withMappedOpenIapError { requireModule().mutationHandlers.restorePurchases?.invoke() } } override suspend fun finishTransaction(purchase: PurchaseInput, isConsumable: Boolean?) { - requireModule().finishTransaction(purchase.toOpenIap(), isConsumable) + withMappedOpenIapError { + requireModule().finishTransaction(purchase.toOpenIap(), isConsumable) + } } override suspend fun acknowledgePurchaseAndroid(purchaseToken: String): Boolean = - requireModule().acknowledgePurchaseAndroid(purchaseToken) + withMappedOpenIapError { requireModule().acknowledgePurchaseAndroid(purchaseToken) } override suspend fun consumePurchaseAndroid(purchaseToken: String): Boolean = - requireModule().consumePurchaseAndroid(purchaseToken) + withMappedOpenIapError { requireModule().consumePurchaseAndroid(purchaseToken) } override suspend fun deepLinkToSubscriptions(options: DeepLinkOptions?) { - options?.let { requireModule().mutationHandlers.deepLinkToSubscriptions?.invoke(it.toOpenIap()) } + withMappedOpenIapError { + options?.let { + requireModule().mutationHandlers.deepLinkToSubscriptions?.invoke(it.toOpenIap()) + } + } } override suspend fun getStorefront(): String = - requireModule().queryHandlers.getStorefront?.invoke().orEmpty().ifBlank { Locale.getDefault().country } + withMappedOpenIapError { + requireModule().queryHandlers.getStorefront?.invoke().orEmpty() + .ifBlank { Locale.getDefault().country } + } override suspend fun getStorefrontIOS(): String = getStorefront() @@ -197,9 +221,21 @@ internal class AmazonInAppPurchaseAndroid( override suspend fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid = BillingProgramAvailabilityResultAndroid(billingProgram = program, isAvailable = false) - override suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid = + override suspend fun getBillingChoiceInfoAndroid(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid = + failUnsupported("Amazon Appstore does not support Google Play Billing Choice.") + + override suspend fun createBillingProgramReportingDetailsAndroid( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? + ): BillingProgramReportingDetailsAndroid = failUnsupported("Amazon Appstore does not support Google Play billing programs.") + override suspend fun showBillingProgramInformationDialogAndroid(params: BillingProgramInformationDialogParamsAndroid): BillingResultAndroid = + failUnsupported("Amazon Appstore does not support Google Play Billing Choice.") + + override suspend fun showInAppMessagesAndroid(params: InAppMessageParamsAndroid?): InAppMessageResultAndroid = + failUnsupported("Google Play billing in-app messages are unavailable on $storeName.") + override suspend fun launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid): Boolean = false override suspend fun checkAlternativeBillingAvailabilityAndroid(): Boolean = false override suspend fun showAlternativeBillingDialogAndroid(): Boolean = false @@ -343,7 +379,7 @@ internal class AmazonInAppPurchaseAndroid( _purchaseUpdatedListener.tryEmit(purchase.toKmp()) } val purchaseError = OpenIapPurchaseErrorListener { error -> - _purchaseErrorListener.tryEmit(error.toKmp()) + _purchaseErrorListener.tryEmit(error.toKmpPurchaseError()) } val userChoice = OpenIapUserChoiceBillingListener { details -> _userChoiceBillingListener.tryEmit(UserChoiceBillingDetails.fromJson(details.toJson())) @@ -381,11 +417,12 @@ internal class AmazonInAppPurchaseAndroid( subscriptionIssueListener = null } - private fun AndroidOpenIapError.toKmp(): PurchaseError = - PurchaseError( - code = ErrorCode.fromJson(code), - message = debugMessage?.takeIf { it.isNotBlank() } ?: message - ) + private suspend fun withMappedOpenIapError(block: suspend () -> T): T = + try { + block() + } catch (error: AndroidOpenIapError) { + throw PurchaseException(error.toKmpPurchaseError()) + } private fun failWith(error: PurchaseError): Nothing { _purchaseErrorListener.tryEmit(error) @@ -396,6 +433,12 @@ internal class AmazonInAppPurchaseAndroid( failWith(PurchaseError(code = ErrorCode.FeatureNotSupported, message = message)) } +internal fun AndroidOpenIapError.toKmpPurchaseError(): PurchaseError = + PurchaseError( + code = ErrorCode.fromJson(code), + message = debugMessage?.takeIf { it.isNotBlank() } ?: message + ) + private fun InitConnectionConfig.toOpenIap(): dev.hyo.openiap.InitConnectionConfig = dev.hyo.openiap.InitConnectionConfig.fromJson(toJson()) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 4ce86137c..16dbab3f7 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -45,6 +45,7 @@ import io.github.hyochan.kmpiap.openiap.SubscriptionPeriod import io.github.hyochan.kmpiap.openiap.SubscriptionPeriodUnit import io.github.hyochan.kmpiap.openiap.SubscriptionReplacementModeAndroid import io.github.hyochan.kmpiap.openiap.ValidTimeWindowAndroid +import io.github.hyochan.kmpiap.openiap.SubResponseCodeAndroid import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -240,6 +241,16 @@ internal fun mapBillingResponseCode(responseCode: Int): ErrorCode = when (respon else -> ErrorCode.Unknown } +internal fun Int.toOpenIapSubResponseCode(): SubResponseCodeAndroid? = when (this) { + BillingClient.OnPurchasesUpdatedSubResponseCode.NO_APPLICABLE_SUB_RESPONSE_CODE -> + SubResponseCodeAndroid.NoApplicableSubResponseCode + BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -> + SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds + BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE -> + SubResponseCodeAndroid.UserIneligible + else -> null +} + /** * Maps SubscriptionReplacementModeAndroid to BillingFlowParams product-level replacement mode. */ @@ -260,6 +271,15 @@ internal fun mapReplacementMode(mode: SubscriptionReplacementModeAndroid): Int? BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode.KEEP_EXISTING } +internal fun resolveLegacySubscriptionReplacementMode( + purchaseToken: String?, + originalExternalTransactionId: String?, + replacementMode: Int?, + hasProductLevelReplacementParams: Boolean = false +): Int? = if (hasProductLevelReplacementParams) null else replacementMode ?: 5.takeIf { + !purchaseToken.isNullOrBlank() && originalExternalTransactionId.isNullOrBlank() +} + internal fun enablePendingPurchasesCompat(builder: BillingClient.Builder): BillingClient.Builder { return try { val paramsClass = Class.forName("com.android.billingclient.api.PendingPurchasesParams") @@ -375,6 +395,8 @@ internal suspend fun loadProductDetails( return details } +internal fun Long.toOpenIapTransactionDate(): Double = toDouble() + internal fun com.android.billingclient.api.Purchase.toPurchase(): Purchase { val purchaseStateEnum = when (purchaseState) { com.android.billingclient.api.Purchase.PurchaseState.PURCHASED -> PurchaseState.Purchased @@ -403,7 +425,7 @@ internal fun com.android.billingclient.api.Purchase.toPurchase(): Purchase { purchaseToken = purchaseToken, quantity = quantity, signatureAndroid = signature, - transactionDate = purchaseTime.toDouble() / 1000 + transactionDate = purchaseTime.toOpenIapTransactionDate() ) } @@ -701,6 +723,7 @@ internal fun BillingProgramAndroid.toOpenIapProgram(): OpenIapBillingProgram = w BillingProgramAndroid.ExternalContentLink -> OpenIapBillingProgram.ExternalContentLink BillingProgramAndroid.ExternalOffer -> OpenIapBillingProgram.ExternalOffer BillingProgramAndroid.ExternalPayments -> OpenIapBillingProgram.ExternalPayments + BillingProgramAndroid.BillingChoice -> OpenIapBillingProgram.BillingChoice BillingProgramAndroid.UserChoiceBilling -> OpenIapBillingProgram.UserChoiceBilling } 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 f89bd048c..46a26b0b0 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 @@ -13,9 +13,14 @@ import com.android.billingclient.api.AlternativeBillingOnlyReportingDetails import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingProgramAvailabilityDetails +import com.android.billingclient.api.BillingProgramReportingDetailsParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.ConsumeParams import com.android.billingclient.api.ExternalOfferReportingDetails +import com.android.billingclient.api.GetBillingChoiceInfoParams +import com.android.billingclient.api.InAppMessageParams +import com.android.billingclient.api.InAppMessageResult import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsResult @@ -51,6 +56,7 @@ import io.github.hyochan.kmpiap.openiap.MutationHandlers import io.github.hyochan.kmpiap.openiap.Product import io.github.hyochan.kmpiap.openiap.ProductOrSubscription import io.github.hyochan.kmpiap.openiap.ProductQueryType +import io.github.hyochan.kmpiap.openiap.ProductType import io.github.hyochan.kmpiap.openiap.ProductRequest import io.github.hyochan.kmpiap.openiap.ProductStatusAndroid import io.github.hyochan.kmpiap.openiap.Purchase @@ -86,14 +92,26 @@ import io.github.hyochan.kmpiap.openiap.PurchaseVerificationProvider import io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult import io.github.hyochan.kmpiap.openiap.IapStore import io.github.hyochan.kmpiap.openiap.IapkitPurchaseState +import io.github.hyochan.kmpiap.openiap.BillingChoiceImageLayoutAndroid +import io.github.hyochan.kmpiap.openiap.BillingChoiceInfoAndroid +import io.github.hyochan.kmpiap.openiap.BillingChoiceScreenTypeAndroid import io.github.hyochan.kmpiap.openiap.BillingProgramAndroid import io.github.hyochan.kmpiap.openiap.BillingProgramAvailabilityResultAndroid +import io.github.hyochan.kmpiap.openiap.BillingProgramInformationDialogParamsAndroid import io.github.hyochan.kmpiap.openiap.BillingProgramReportingDetailsAndroid +import io.github.hyochan.kmpiap.openiap.BillingResultAndroid import io.github.hyochan.kmpiap.openiap.DeveloperBillingLaunchModeAndroid import io.github.hyochan.kmpiap.openiap.DeveloperBillingOptionParamsAndroid +import io.github.hyochan.kmpiap.openiap.DeveloperBillingTypeAndroid import io.github.hyochan.kmpiap.openiap.DeveloperProvidedBillingDetailsAndroid +import io.github.hyochan.kmpiap.openiap.DeveloperProvidedBillingProductAndroid import io.github.hyochan.kmpiap.openiap.ExternalLinkLaunchModeAndroid import io.github.hyochan.kmpiap.openiap.ExternalLinkTypeAndroid +import io.github.hyochan.kmpiap.openiap.GetBillingChoiceInfoParamsAndroid +import io.github.hyochan.kmpiap.openiap.InAppMessageCategoryAndroid +import io.github.hyochan.kmpiap.openiap.InAppMessageParamsAndroid +import io.github.hyochan.kmpiap.openiap.InAppMessageResponseCodeAndroid +import io.github.hyochan.kmpiap.openiap.InAppMessageResultAndroid import io.github.hyochan.kmpiap.openiap.LaunchExternalLinkParamsAndroid import io.github.hyochan.kmpiap.openiap.SubscriptionProductReplacementParamsAndroid import io.github.hyochan.kmpiap.openiap.SubscriptionReplacementModeAndroid @@ -134,6 +152,42 @@ private fun logError(message: String, throwable: Throwable) { } } +private fun Any.invokeOptionalStringGetter(methodName: String): String? = + runCatching { javaClass.getMethod(methodName).invoke(this) as? String } + .getOrNull() + ?.takeIf { it.isNotBlank() } + +private fun Any.invokeOptionalListGetter(methodName: String): List<*> = + runCatching { javaClass.getMethod(methodName).invoke(this) as? List<*> } + .getOrNull() + .orEmpty() + +internal fun extractDeveloperProvidedBillingDetails( + details: Any +): DeveloperProvidedBillingDetailsAndroid { + val products = details.invokeOptionalListGetter("getProducts").mapNotNull { product -> + product ?: return@mapNotNull null + val id = product.invokeOptionalStringGetter("getId") ?: return@mapNotNull null + val type = when (product.invokeOptionalStringGetter("getType")) { + BillingClient.ProductType.INAPP -> ProductType.InApp + BillingClient.ProductType.SUBS -> ProductType.Subs + else -> return@mapNotNull null + } + DeveloperProvidedBillingProductAndroid( + id = id, + offerToken = product.invokeOptionalStringGetter("getOfferToken"), + type = type + ) + } + + return DeveloperProvidedBillingDetailsAndroid( + externalTransactionToken = details.invokeOptionalStringGetter("getExternalTransactionToken"), + linkUri = details.invokeOptionalStringGetter("getLinkUri"), + originalExternalTransactionId = details.invokeOptionalStringGetter("getOriginalExternalTransactionId"), + products = products + ) +} + internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLifecycleCallbacks { private var billingClient: BillingClient? = null @@ -153,6 +207,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife private var purchaseTimeoutRunnable: Runnable? = null private var alternativeBillingMode: AlternativeBillingModeAndroid = AlternativeBillingModeAndroid.None private var enabledBillingProgram: BillingProgramAndroid? = null + private var billingChoiceScreenType: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GoogleRendered // --------------------------------------------------------------------- // Event streams @@ -192,6 +247,9 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife alternativeBillingMode = config?.alternativeBillingModeAndroid ?: AlternativeBillingModeAndroid.None // Track enabled billing program for validation enabledBillingProgram = config?.enableBillingProgramAndroid + billingChoiceScreenType = config?.billingChoiceScreenTypeAndroid + ?.takeUnless { it == BillingChoiceScreenTypeAndroid.Unspecified } + ?: BillingChoiceScreenTypeAndroid.GoogleRendered if (context == null) { val disposer = tryCaptureApplication( @@ -261,10 +319,16 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife } } - // Enable billing program from config (8.2.0+, EXTERNAL_PAYMENTS requires 8.3.0+) + // Enable billing program from config (8.2.0+, EXTERNAL_PAYMENTS requires 8.3.0+, BILLING_CHOICE requires 9.1.0+) config?.enableBillingProgramAndroid?.let { program -> - if (program == BillingProgramAndroid.ExternalPayments) { - enableExternalPaymentsProgram(builder) + if (program == BillingProgramAndroid.ExternalPayments || program == BillingProgramAndroid.BillingChoice) { + enableDeveloperProvidedBillingProgram( + builder, + program, + includeDeveloperListener = + program == BillingProgramAndroid.ExternalPayments || + billingChoiceScreenType != BillingChoiceScreenTypeAndroid.DeveloperRendered + ) } else { enableBillingProgram(builder, program) } @@ -303,13 +367,16 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife val purchases = withContext(Dispatchers.Main) { val resolvedType = props.type - val purchaseAndroidOptions = (props.request as? RequestPurchaseProps.Request.Purchase)?.value?.android - val subscriptionAndroidOptions = (props.request as? RequestPurchaseProps.Request.Subscription)?.value?.android + val purchaseRequest = (props.request as? RequestPurchaseProps.Request.Purchase)?.value + val subscriptionRequest = (props.request as? RequestPurchaseProps.Request.Subscription)?.value + val purchaseAndroidOptions = purchaseRequest?.google ?: purchaseRequest?.android + val subscriptionAndroidOptions = subscriptionRequest?.google ?: subscriptionRequest?.android val subscriptionOffers: List = subscriptionAndroidOptions?.subscriptionOffers.orEmpty() val purchaseToken = subscriptionAndroidOptions?.purchaseToken + val originalExternalTransactionId = subscriptionAndroidOptions?.originalExternalTransactionId val replacementMode = subscriptionAndroidOptions?.replacementMode val subscriptionProductReplacementParams = subscriptionAndroidOptions?.subscriptionProductReplacementParams @@ -454,19 +521,30 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife flowBuilder.setObfuscatedProfileId(profileId) } - if (desiredProductType == BillingClient.ProductType.SUBS && !purchaseToken.isNullOrEmpty()) { + val hasSubscriptionUpdateSource = + !purchaseToken.isNullOrEmpty() || !originalExternalTransactionId.isNullOrEmpty() + if (desiredProductType == BillingClient.ProductType.SUBS && hasSubscriptionUpdateSource) { val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder() - .setOldPurchaseToken(purchaseToken) - if (subscriptionProductReplacementParams == null) { - replacementMode?.let { mode -> - @Suppress("DEPRECATION") - updateParamsBuilder.setSubscriptionReplacementMode(mode) - } + purchaseToken?.takeIf { it.isNotEmpty() }?.let { + updateParamsBuilder.setOldPurchaseToken(it) + } + originalExternalTransactionId?.takeIf { it.isNotEmpty() }?.let { + updateParamsBuilder.setOriginalExternalTransactionId(it) + } + val legacyReplacementMode = resolveLegacySubscriptionReplacementMode( + purchaseToken = purchaseToken, + originalExternalTransactionId = originalExternalTransactionId, + replacementMode = replacementMode, + hasProductLevelReplacementParams = subscriptionProductReplacementParams != null + ) + legacyReplacementMode?.let { mode -> + @Suppress("DEPRECATION") + updateParamsBuilder.setSubscriptionReplacementMode(mode) } flowBuilder.setSubscriptionUpdateParams(updateParamsBuilder.build()) } - // Apply developer billing option for External Payments flow (8.3.0+) + // Apply the developer billing option for External Payments (8.3.0+) or Billing Choice (9.1.0+) developerBillingOption?.let { option -> applyDeveloperBillingOption(flowBuilder, option) } @@ -1046,7 +1124,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife isActive = true, productId = purchase.products.firstOrNull().orEmpty(), purchaseToken = purchase.purchaseToken, - transactionDate = purchase.purchaseTime.toDouble() / 1000, + transactionDate = purchase.purchaseTime.toOpenIapTransactionDate(), transactionId = purchase.orderId ?: purchase.purchaseToken, willExpireSoon = null, daysUntilExpirationIOS = null, @@ -1104,6 +1182,81 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife // --------------------------------------------------------------------- // Android specific overrides // --------------------------------------------------------------------- + private fun billingProgramConstant(program: BillingProgramAndroid, operation: String): Int = + when (program) { + BillingProgramAndroid.ExternalContentLink -> BillingClient.BillingProgram.EXTERNAL_CONTENT_LINK + BillingProgramAndroid.UserChoiceBilling -> 2 + BillingProgramAndroid.ExternalOffer -> BillingClient.BillingProgram.EXTERNAL_OFFER + BillingProgramAndroid.ExternalPayments -> BillingClient.BillingProgram.EXTERNAL_PAYMENTS + BillingProgramAndroid.BillingChoice -> BillingClient.BillingProgram.BILLING_CHOICE + BillingProgramAndroid.Unspecified -> throw PurchaseException( + PurchaseError( + code = ErrorCode.DeveloperError, + message = "Cannot $operation with UNSPECIFIED program" + ) + ) + } + + private fun billingChoiceImageLayout(layout: BillingChoiceImageLayoutAndroid): String = + when (layout) { + BillingChoiceImageLayoutAndroid.RectangularFourByOne -> + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_FOUR_BY_ONE + BillingChoiceImageLayoutAndroid.RectangularThreeByOne -> + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_THREE_BY_ONE + BillingChoiceImageLayoutAndroid.RectangularTwoByTwo -> + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_TWO_BY_TWO + } + + private fun developerBillingTypeConstant( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? + ): Int? = + when (developerBillingType) { + DeveloperBillingTypeAndroid.InApp -> + BillingProgramReportingDetailsParams.DeveloperBillingType.IN_APP + DeveloperBillingTypeAndroid.ExternalLink -> + BillingProgramReportingDetailsParams.DeveloperBillingType.EXTERNAL_LINK + DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified, null -> + if (program == BillingProgramAndroid.BillingChoice) { + BillingProgramReportingDetailsParams.DeveloperBillingType.IN_APP + } else { + null + } + } + + private fun billingChoiceScreenTypeFromConstant(value: Int?): BillingChoiceScreenTypeAndroid? = + when (value) { + BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails.ChoiceScreenType.UNSPECIFIED -> + BillingChoiceScreenTypeAndroid.Unspecified + BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails.ChoiceScreenType.DEVELOPER_RENDERED -> + BillingChoiceScreenTypeAndroid.DeveloperRendered + BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails.ChoiceScreenType.GOOGLE_RENDERED -> + BillingChoiceScreenTypeAndroid.GoogleRendered + else -> null + } + + private fun inAppMessageCategoryConstant(category: InAppMessageCategoryAndroid): Int = + when (category) { + InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId -> + InAppMessageParams.InAppMessageCategoryId.UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID + InAppMessageCategoryAndroid.Transactional -> + InAppMessageParams.InAppMessageCategoryId.TRANSACTIONAL + } + + private fun inAppMessageResponseCodeFromConstant(value: Int?): InAppMessageResponseCodeAndroid = + when (value) { + InAppMessageResult.InAppMessageResponseCode.SUBSCRIPTION_STATUS_UPDATED -> + InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + else -> InAppMessageResponseCodeAndroid.NoActionNeeded + } + + private fun BillingResult.toBillingResultAndroid(): BillingResultAndroid = + BillingResultAndroid( + responseCode = responseCode, + debugMessage = debugMessage, + subResponseCode = onPurchasesUpdatedSubResponseCode.toOpenIapSubResponseCode() + ) + /** * Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. * @@ -1646,7 +1799,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife // --------------------------------------------------------------------- // Billing Programs API (Android 8.2.1+) // These APIs use reflection to maintain compatibility with older Billing Library versions - // Full implementation uses Google Play Billing Library 8.2.1 + // Full implementation uses Google Play Billing Library 9.1.0 // --------------------------------------------------------------------- /** @@ -1664,19 +1817,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ) ) - // Convert our enum to BillingClient.BillingProgram constant - val billingProgramConstant = when (program) { - BillingProgramAndroid.UserChoiceBilling -> 2 // USER_CHOICE_BILLING (7.0+) - BillingProgramAndroid.ExternalContentLink -> 1 // EXTERNAL_CONTENT_LINK - BillingProgramAndroid.ExternalOffer -> 3 // EXTERNAL_OFFER - BillingProgramAndroid.ExternalPayments -> 4 // EXTERNAL_PAYMENTS (8.3.0+) - BillingProgramAndroid.Unspecified -> throw PurchaseException( - PurchaseError( - code = ErrorCode.DeveloperError, - message = "Cannot check availability for UNSPECIFIED program" - ) - ) - } + val billingProgramConstant = billingProgramConstant(program, "check availability") return suspendCancellableCoroutine { continuation -> try { @@ -1689,10 +1830,33 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife if (method.name == "onBillingProgramAvailabilityResponse") { val result = args?.get(0) as? BillingResult val isAvailable = result?.responseCode == BillingClient.BillingResponseCode.OK + val availabilityDetails = args?.getOrNull(1) + val choiceDetails = + if (program == BillingProgramAndroid.BillingChoice) { + runCatching { + availabilityDetails?.javaClass + ?.getMethod("getBillingChoiceAvailabilityDetails") + ?.invoke(availabilityDetails) + }.getOrNull() + } else { + null + } + val choiceScreenType = runCatching { + choiceDetails?.javaClass + ?.getMethod("getChoiceScreenType") + ?.invoke(choiceDetails) as? Int + }.getOrNull() + val isExternalLinkAvailable = runCatching { + choiceDetails?.javaClass + ?.getMethod("isExternalLinkAvailable") + ?.invoke(choiceDetails) as? Boolean + }.getOrNull() if (continuation.isActive) { continuation.resume(BillingProgramAvailabilityResultAndroid( billingProgram = program, - isAvailable = isAvailable + choiceScreenType = billingChoiceScreenTypeFromConstant(choiceScreenType), + isAvailable = isAvailable, + isExternalLinkAvailable = isExternalLinkAvailable )) } } @@ -1733,7 +1897,8 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife * @see https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ override suspend fun createBillingProgramReportingDetailsAndroid( - program: BillingProgramAndroid + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? ): BillingProgramReportingDetailsAndroid { val client = billingClient ?: throw PurchaseException( PurchaseError( @@ -1742,27 +1907,26 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ) ) - val billingProgramConstant = when (program) { - BillingProgramAndroid.UserChoiceBilling -> 2 - BillingProgramAndroid.ExternalContentLink -> 1 - BillingProgramAndroid.ExternalOffer -> 3 - BillingProgramAndroid.ExternalPayments -> 4 - BillingProgramAndroid.Unspecified -> throw PurchaseException( - PurchaseError( - code = ErrorCode.DeveloperError, - message = "Cannot create reporting details for UNSPECIFIED program" - ) - ) - } + val billingProgramConstant = billingProgramConstant(program, "create reporting details") return suspendCancellableCoroutine { continuation -> try { + val paramsClass = Class.forName("com.android.billingclient.api.BillingProgramReportingDetailsParams") + val builderClass = Class.forName("com.android.billingclient.api.BillingProgramReportingDetailsParams\$Builder") + val builder = paramsClass.getMethod("newBuilder").invoke(null) + builderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) + .invoke(builder, billingProgramConstant) + developerBillingTypeConstant(program, developerBillingType)?.let { typeConstant -> + builderClass.getMethod("setDeveloperBillingType", Int::class.javaPrimitiveType) + .invoke(builder, typeConstant) + } + val requestParams = builderClass.getMethod("build").invoke(builder) val listenerClass = Class.forName("com.android.billingclient.api.BillingProgramReportingDetailsListener") val listener = java.lang.reflect.Proxy.newProxyInstance( listenerClass.classLoader, arrayOf(listenerClass) ) { _, method, args -> - if (method.name == "onBillingProgramReportingDetailsResponse") { + if (method.name == "onCreateBillingProgramReportingDetailsResponse") { val result = args?.get(0) as? BillingResult val details = args?.getOrNull(1) @@ -1808,10 +1972,10 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife val method = client.javaClass.getMethod( "createBillingProgramReportingDetailsAsync", - Int::class.javaPrimitiveType, + paramsClass, listenerClass ) - method.invoke(client, billingProgramConstant, listener) + method.invoke(client, requestParams, listener) } catch (e: NoSuchMethodException) { if (continuation.isActive) { continuation.resumeWithException(PurchaseException( @@ -1834,12 +1998,333 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife } } + override suspend fun getBillingChoiceInfoAndroid( + params: GetBillingChoiceInfoParamsAndroid + ): BillingChoiceInfoAndroid { + val client = billingClient ?: throw PurchaseException( + PurchaseError( + code = ErrorCode.NotPrepared, + message = "BillingClient not initialized" + ) + ) + if (!client.isReady) throw PurchaseException( + PurchaseError( + code = ErrorCode.NotPrepared, + message = "BillingClient not ready" + ) + ) + val program = if (params.billingProgram == BillingProgramAndroid.Unspecified) { + BillingProgramAndroid.BillingChoice + } else { + params.billingProgram + } + if (program != BillingProgramAndroid.BillingChoice) { + throw PurchaseException( + PurchaseError( + code = ErrorCode.DeveloperError, + message = "getBillingChoiceInfoAndroid only supports BillingChoice" + ) + ) + } + + return suspendCancellableCoroutine { continuation -> + try { + val paramsClass = Class.forName("com.android.billingclient.api.GetBillingChoiceInfoParams") + val builderClass = Class.forName("com.android.billingclient.api.GetBillingChoiceInfoParams\$Builder") + val builder = paramsClass.getMethod("newBuilder").invoke(null) + builderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) + .invoke(builder, billingProgramConstant(program, "get Billing Choice info")) + builderClass.getMethod("setPlayBillingChoiceImageLayout", String::class.java) + .invoke(builder, billingChoiceImageLayout(params.playBillingChoiceImageLayout)) + params.userLocale?.takeIf { it.isNotBlank() }?.let { languageTag -> + builderClass.getMethod("setUserLocale", java.util.Locale::class.java) + .invoke(builder, java.util.Locale.forLanguageTag(languageTag)) + } + val requestParams = builderClass.getMethod("build").invoke(builder) + val listenerClass = Class.forName("com.android.billingclient.api.BillingChoiceInfoResponseListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onBillingChoiceInfoResponse") { + val result = args?.get(0) as? BillingResult + val choiceInfo = args?.getOrNull(1) + if (result?.responseCode == BillingClient.BillingResponseCode.OK && choiceInfo != null) { + val imageUrl = choiceInfo.javaClass + .getMethod("getPlayBillingChoiceImageUrl") + .invoke(choiceInfo) as? String + val loyaltyInfo = choiceInfo.javaClass + .getMethod("getPlayBillingLoyaltyInfo") + .invoke(choiceInfo) as? String + if (imageUrl.isNullOrBlank()) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.Unknown, + message = "Missing Play Billing choice image URL" + ) + )) + } + } else if (continuation.isActive) { + continuation.resume(BillingChoiceInfoAndroid( + playBillingChoiceImageUrl = imageUrl, + playBillingLoyaltyInfo = loyaltyInfo + )) + } + } else if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.Unknown, + message = "Billing Choice info failed: ${result?.debugMessage}" + ) + )) + } + } + null + } + client.javaClass.getMethod("getBillingChoiceInfoAsync", paramsClass, listenerClass) + .invoke(client, requestParams, listener) + } catch (e: NoSuchMethodException) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.FeatureNotSupported, + message = "getBillingChoiceInfoAsync requires Billing Library 9.1.0+" + ) + )) + } + } catch (e: ClassNotFoundException) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.FeatureNotSupported, + message = "GetBillingChoiceInfoParams requires Billing Library 9.1.0+" + ) + )) + } + } catch (e: Exception) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.Unknown, + message = "Failed to get Billing Choice info: ${e.message}" + ) + )) + } + } + } + } + + override suspend fun showBillingProgramInformationDialogAndroid( + params: BillingProgramInformationDialogParamsAndroid + ): BillingResultAndroid = withContext(Dispatchers.Main) { + val client = billingClient ?: throw PurchaseException( + PurchaseError( + code = ErrorCode.NotPrepared, + message = "BillingClient not initialized" + ) + ) + if (!client.isReady) throw PurchaseException( + PurchaseError( + code = ErrorCode.NotPrepared, + message = "BillingClient not ready" + ) + ) + val activity = currentActivity ?: throw PurchaseException( + PurchaseError( + code = ErrorCode.NotPrepared, + message = "Activity not available" + ) + ) + val program = if (params.billingProgram == BillingProgramAndroid.Unspecified) { + BillingProgramAndroid.BillingChoice + } else { + params.billingProgram + } + if (program != BillingProgramAndroid.BillingChoice) { + throw PurchaseException( + PurchaseError( + code = ErrorCode.DeveloperError, + message = "showBillingProgramInformationDialogAndroid only supports BillingChoice" + ) + ) + } + + suspendCancellableCoroutine { continuation -> + try { + val paramsClass = Class.forName("com.android.billingclient.api.BillingProgramInformationDialogParams") + val builderClass = Class.forName("com.android.billingclient.api.BillingProgramInformationDialogParams\$Builder") + val builder = paramsClass.getMethod("newBuilder").invoke(null) + builderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) + .invoke(builder, billingProgramConstant(program, "show Billing Choice information dialog")) + builderClass.getMethod("setExternalTransactionToken", String::class.java) + .invoke(builder, params.externalTransactionToken) + val requestParams = builderClass.getMethod("build").invoke(builder) + val listenerClass = Class.forName("com.android.billingclient.api.BillingProgramInformationDialogListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onBillingProgramInformationDialogResponse") { + val result = args?.get(0) as? BillingResult + if (continuation.isActive) { + continuation.resume( + result?.toBillingResultAndroid() + ?: BillingResultAndroid(responseCode = BillingClient.BillingResponseCode.ERROR) + ) + } + } + null + } + client.javaClass.getMethod( + "showBillingProgramInformationDialog", + Activity::class.java, + paramsClass, + listenerClass + ).invoke(client, activity, requestParams, listener) + } catch (e: NoSuchMethodException) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.FeatureNotSupported, + message = "showBillingProgramInformationDialog requires Billing Library 9.1.0+" + ) + )) + } + } catch (e: ClassNotFoundException) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.FeatureNotSupported, + message = "BillingProgramInformationDialogParams requires Billing Library 9.1.0+" + ) + )) + } + } catch (e: Exception) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.Unknown, + message = "Failed to show Billing Choice information dialog: ${e.message}" + ) + )) + } + } + } + } + + override suspend fun showInAppMessagesAndroid( + params: InAppMessageParamsAndroid? + ): InAppMessageResultAndroid = withContext(Dispatchers.Main) { + val client = billingClient ?: throw PurchaseException( + PurchaseError( + code = ErrorCode.NotPrepared, + message = "BillingClient not initialized" + ) + ) + if (!client.isReady) throw PurchaseException( + PurchaseError( + code = ErrorCode.NotPrepared, + message = "BillingClient not ready" + ) + ) + val activity = currentActivity ?: throw PurchaseException( + PurchaseError( + code = ErrorCode.NotPrepared, + message = "Activity not available" + ) + ) + + suspendCancellableCoroutine { continuation -> + try { + val paramsClass = Class.forName("com.android.billingclient.api.InAppMessageParams") + val builderClass = Class.forName("com.android.billingclient.api.InAppMessageParams\$Builder") + val builder = paramsClass.getMethod("newBuilder").invoke(null) + val categories = params?.categories?.takeIf { it.isNotEmpty() } + ?: listOf(InAppMessageCategoryAndroid.Transactional) + for (category in categories) { + builderClass.getMethod("addInAppMessageCategoryToShow", Int::class.javaPrimitiveType) + .invoke(builder, inAppMessageCategoryConstant(category)) + } + val requestParams = builderClass.getMethod("build").invoke(builder) + val listenerClass = Class.forName("com.android.billingclient.api.InAppMessageResponseListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onInAppMessageResponse") { + val result = args?.get(0) + val responseCode = runCatching { + result?.javaClass?.getMethod("getResponseCode")?.invoke(result) as? Int + }.getOrNull() + val purchaseToken = runCatching { + result?.javaClass?.getMethod("getPurchaseToken")?.invoke(result) as? String + }.getOrNull() + if (continuation.isActive) { + continuation.resume(InAppMessageResultAndroid( + responseCode = inAppMessageResponseCodeFromConstant(responseCode), + purchaseToken = purchaseToken + )) + } + } + null + } + val submitResult = client.javaClass.getMethod( + "showInAppMessages", + Activity::class.java, + paramsClass, + listenerClass + ).invoke(client, activity, requestParams, listener) as? BillingResult + if (submitResult != null && + submitResult.responseCode != BillingClient.BillingResponseCode.OK && + continuation.isActive + ) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.Unknown, + message = "showInAppMessages failed: ${submitResult.debugMessage}" + ) + )) + } + } catch (e: NoSuchMethodException) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.FeatureNotSupported, + message = "showInAppMessages requires Billing Library 4.1.0+" + ) + )) + } + } catch (e: ClassNotFoundException) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.FeatureNotSupported, + message = "InAppMessageParams requires Billing Library 4.1.0+" + ) + )) + } + } catch (e: Exception) { + if (continuation.isActive) { + continuation.resumeWithException(PurchaseException( + PurchaseError( + code = ErrorCode.Unknown, + message = "Failed to show in-app messages: ${e.message}" + ) + )) + } + } + } + } + /** * Launch an external content/offer link (Play Billing 8.2.0+). * * @see https://openiap.dev/docs/apis/android/launch-external-link-android */ - override suspend fun launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid): Boolean { + override suspend fun launchExternalLinkAndroid( + params: LaunchExternalLinkParamsAndroid + ): Boolean = withContext(Dispatchers.Main) { val client = billingClient ?: throw PurchaseException( PurchaseError( code = ErrorCode.NotPrepared, @@ -1855,18 +2340,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ) // Convert enums to BillingClient constants - val billingProgramConstant = when (params.billingProgram) { - BillingProgramAndroid.UserChoiceBilling -> 2 - BillingProgramAndroid.ExternalContentLink -> 1 - BillingProgramAndroid.ExternalOffer -> 3 - BillingProgramAndroid.ExternalPayments -> 4 - BillingProgramAndroid.Unspecified -> throw PurchaseException( - PurchaseError( - code = ErrorCode.DeveloperError, - message = "Cannot launch with UNSPECIFIED program" - ) - ) - } + val billingProgramConstant = billingProgramConstant(params.billingProgram, "launch") val launchModeConstant = when (params.launchMode) { ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp -> 1 @@ -1890,7 +2364,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ) } - return suspendCancellableCoroutine { continuation -> + suspendCancellableCoroutine { continuation -> try { // Build LaunchExternalLinkParams using reflection val paramsClass = Class.forName("com.android.billingclient.api.LaunchExternalLinkParams") @@ -1915,6 +2389,11 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife val setLinkUriMethod = builderClass.getMethod("setLinkUri", Uri::class.java) setLinkUriMethod.invoke(builder, Uri.parse(params.linkUri)) + params.externalTransactionToken?.takeIf { it.isNotBlank() }?.let { token -> + builderClass.getMethod("setExternalTransactionToken", String::class.java) + .invoke(builder, token) + } + // Build the params val buildMethod = builderClass.getMethod("build") val launchParams = buildMethod.invoke(builder) @@ -1930,14 +2409,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife if (result?.responseCode == BillingClient.BillingResponseCode.OK) { if (continuation.isActive) continuation.resume(true) } else { - if (continuation.isActive) { - continuation.resumeWithException(PurchaseException( - PurchaseError( - code = ErrorCode.Unknown, - message = "External link launch failed: ${result?.debugMessage}" - ) - )) - } + if (continuation.isActive) continuation.resume(false) } } null @@ -1952,23 +2424,9 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ) launchMethod.invoke(client, activity, launchParams, listener) } catch (e: NoSuchMethodException) { - if (continuation.isActive) { - continuation.resumeWithException(PurchaseException( - PurchaseError( - code = ErrorCode.FeatureNotSupported, - message = "launchExternalLink requires Billing Library 8.2.0+" - ) - )) - } + if (continuation.isActive) continuation.resume(false) } catch (e: Exception) { - if (continuation.isActive) { - continuation.resumeWithException(PurchaseException( - PurchaseError( - code = ErrorCode.Unknown, - message = "Failed to launch external link: ${e.message}" - ) - )) - } + if (continuation.isActive) continuation.resume(false) } } } @@ -1997,56 +2455,48 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife } // --------------------------------------------------------------------- - // External Payments Program (8.3.0+) + // Developer-provided billing programs (8.3.0+) // --------------------------------------------------------------------- /** - * Enable External Payments program using reflection for 8.3.0+ compatibility. - * This sets up the DeveloperProvidedBillingListener to receive callbacks - * when the user selects developer billing in the external payments flow. + * Enable External Payments or Billing Choice using the available Play APIs. + * The listener is omitted for developer-rendered Billing Choice. */ - private fun enableExternalPaymentsProgram(builder: BillingClient.Builder) { + private fun enableDeveloperProvidedBillingProgram( + builder: BillingClient.Builder, + program: BillingProgramAndroid, + includeDeveloperListener: Boolean + ) { try { // Get the EnableBillingProgramParams class val enableParamsClass = Class.forName("com.android.billingclient.api.EnableBillingProgramParams") val enableParamsBuilderClass = Class.forName("com.android.billingclient.api.EnableBillingProgramParams\$Builder") - // Create EnableBillingProgramParams for EXTERNAL_PAYMENTS (constant value 4) val newBuilderMethod = enableParamsClass.getMethod("newBuilder") val enableParamsBuilder = newBuilderMethod.invoke(null) val setBillingProgramMethod = enableParamsBuilderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) - setBillingProgramMethod.invoke(enableParamsBuilder, 4) // EXTERNAL_PAYMENTS = 4 - - // Create the DeveloperProvidedBillingListener proxy - val listenerClass = Class.forName("com.android.billingclient.api.DeveloperProvidedBillingListener") - val listener = java.lang.reflect.Proxy.newProxyInstance( - listenerClass.classLoader, - arrayOf(listenerClass) - ) { _, method, args -> - if (method.name == "onDeveloperProvidedBillingDetails") { - val details = args?.get(0) - if (details != null) { - try { - val tokenMethod = details.javaClass.getMethod("getExternalTransactionToken") - val token = tokenMethod.invoke(details) as? String - if (token != null) { - val billingDetails = DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken = token - ) - _developerProvidedBillingListener.tryEmit(billingDetails) - } - } catch (e: Exception) { - android.util.Log.e("KmpIAP", "Failed to extract developer billing token: ${e.message}") + setBillingProgramMethod.invoke(enableParamsBuilder, billingProgramConstant(program, "enable billing program")) + + if (includeDeveloperListener) { + val listenerClass = Class.forName("com.android.billingclient.api.DeveloperProvidedBillingListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onUserSelectedDeveloperBilling") { + args?.firstOrNull()?.let { details -> + _developerProvidedBillingListener.tryEmit( + extractDeveloperProvidedBillingDetails(details) + ) } } + null } - null - } - // Set the listener on the builder - val setListenerMethod = enableParamsBuilderClass.getMethod("setDeveloperProvidedBillingListener", listenerClass) - setListenerMethod.invoke(enableParamsBuilder, listener) + enableParamsBuilderClass.getMethod("setDeveloperProvidedBillingListener", listenerClass) + .invoke(enableParamsBuilder, listener) + } // Build the params val buildMethod = enableParamsBuilderClass.getMethod("build") @@ -2057,11 +2507,11 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife enableMethod.invoke(builder, enableParams) } catch (e: NoSuchMethodException) { - logWarning("External Payments requires Billing Library 8.3.0+") + logWarning("$program requires Billing Library support for developer-provided billing programs") } catch (e: ClassNotFoundException) { - logWarning("External Payments requires Billing Library 8.3.0+") + logWarning("$program requires Billing Library support for developer-provided billing programs") } catch (e: Exception) { - logError("Failed to enable External Payments program: ${e.message}", e) + logError("Failed to enable $program program: ${e.message}", e) } } @@ -2083,8 +2533,10 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife } return } - BillingProgramAndroid.ExternalContentLink -> 1 - BillingProgramAndroid.ExternalOffer -> 3 + BillingProgramAndroid.ExternalContentLink, + BillingProgramAndroid.ExternalOffer, + BillingProgramAndroid.BillingChoice -> + billingProgramConstant(program, "enable billing program") BillingProgramAndroid.ExternalPayments -> { logWarning("ExternalPayments should use enableExternalPaymentsProgram()") return @@ -2121,59 +2573,78 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife // Set billing program (EXTERNAL_PAYMENTS = 4) val billingProgramConstant = when (option.billingProgram) { BillingProgramAndroid.UserChoiceBilling -> 2 - BillingProgramAndroid.ExternalPayments -> 4 - BillingProgramAndroid.ExternalContentLink -> 1 - BillingProgramAndroid.ExternalOffer -> 3 - BillingProgramAndroid.Unspecified -> 0 + BillingProgramAndroid.ExternalPayments, + BillingProgramAndroid.BillingChoice, + BillingProgramAndroid.ExternalContentLink, + BillingProgramAndroid.ExternalOffer -> + billingProgramConstant(option.billingProgram, "apply developer billing option") + BillingProgramAndroid.Unspecified -> + BillingClient.BillingProgram.UNSPECIFIED_BILLING_PROGRAM } val setBillingProgramMethod = paramsBuilderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) setBillingProgramMethod.invoke(paramsBuilder, billingProgramConstant) - // Set launch mode + // Link fields are optional for in-app Billing Choice flows. val launchModeConstant = when (option.launchMode) { DeveloperBillingLaunchModeAndroid.LaunchInExternalBrowserOrApp -> 1 DeveloperBillingLaunchModeAndroid.CallerWillLaunchLink -> 2 - DeveloperBillingLaunchModeAndroid.Unspecified -> 0 + DeveloperBillingLaunchModeAndroid.Unspecified -> throw IllegalArgumentException( + "Cannot use UNSPECIFIED launch mode" + ) + null -> null + } + launchModeConstant?.let { launchMode -> + paramsBuilderClass.getMethod("setLaunchMode", Int::class.javaPrimitiveType) + .invoke(paramsBuilder, launchMode) + } + + option.linkUri?.takeIf { it.isNotBlank() }?.let { linkUri -> + paramsBuilderClass.getMethod("setLinkUri", android.net.Uri::class.java) + .invoke(paramsBuilder, android.net.Uri.parse(linkUri)) } - val setLaunchModeMethod = paramsBuilderClass.getMethod("setLaunchMode", Int::class.javaPrimitiveType) - setLaunchModeMethod.invoke(paramsBuilder, launchModeConstant) - // Set link URI - val setLinkUriMethod = paramsBuilderClass.getMethod("setLinkUri", android.net.Uri::class.java) - setLinkUriMethod.invoke(paramsBuilder, android.net.Uri.parse(option.linkUri)) + option.externalTransactionToken?.takeIf { it.isNotBlank() }?.let { token -> + paramsBuilderClass.getMethod("setExternalTransactionToken", String::class.java) + .invoke(paramsBuilder, token) + } // Build the params val buildMethod = paramsBuilderClass.getMethod("build") val developerBillingParams = buildMethod.invoke(paramsBuilder) // Apply to BillingFlowParams.Builder - val setDeveloperBillingOptionMethod = flowBuilder.javaClass.getMethod( - "setDeveloperBillingOption", + val enableDeveloperBillingOptionMethod = flowBuilder.javaClass.getMethod( + "enableDeveloperBillingOption", paramsClass ) - setDeveloperBillingOptionMethod.invoke(flowBuilder, developerBillingParams) + enableDeveloperBillingOptionMethod.invoke(flowBuilder, developerBillingParams) } catch (e: NoSuchMethodException) { logWarning("DeveloperBillingOption requires Billing Library 8.3.0+") + throw e } catch (e: ClassNotFoundException) { logWarning("DeveloperBillingOption requires Billing Library 8.3.0+") + throw e } catch (e: Exception) { logError("Failed to apply DeveloperBillingOption: ${e.message}", e) + throw e } } /** * Get the developer provided billing details when user selects developer billing - * in the External Payments flow. + * in an External Payments or Billing Choice flow. * - * @throws PurchaseException if External Payments program is not enabled + * @throws PurchaseException if a developer-provided billing program is not enabled */ override suspend fun developerProvidedBillingAndroid(): DeveloperProvidedBillingDetailsAndroid { - if (enabledBillingProgram != BillingProgramAndroid.ExternalPayments) { + if (enabledBillingProgram != BillingProgramAndroid.ExternalPayments && + enabledBillingProgram != BillingProgramAndroid.BillingChoice + ) { throw PurchaseException( PurchaseError( code = ErrorCode.DeveloperError, - message = "External Payments program not enabled. Set enableBillingProgramAndroid = BillingProgramAndroid.ExternalPayments in InitConnectionConfig." + message = "Developer-provided billing is not enabled. Set enableBillingProgramAndroid to ExternalPayments or BillingChoice." ) ) } diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/AmazonErrorMappingTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/AmazonErrorMappingTest.kt new file mode 100644 index 000000000..602d61a0a --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/AmazonErrorMappingTest.kt @@ -0,0 +1,18 @@ +package io.github.hyochan.kmpiap + +import dev.hyo.openiap.OpenIapError +import io.github.hyochan.kmpiap.openiap.ErrorCode +import kotlin.test.Test +import kotlin.test.assertEquals + +class AmazonErrorMappingTest { + @Test + fun `Amazon cancellation maps to KMP user-cancelled error`() { + val error = OpenIapError.UserCancelled("Amazon purchase was cancelled") + + val mapped = error.toKmpPurchaseError() + + assertEquals(ErrorCode.UserCancelled, mapped.code) + assertEquals("Amazon purchase was cancelled", mapped.message) + } +} diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingResultMappingTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingResultMappingTest.kt new file mode 100644 index 000000000..df2cddbdf --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingResultMappingTest.kt @@ -0,0 +1,33 @@ +package io.github.hyochan.kmpiap + +import com.android.billingclient.api.BillingClient +import io.github.hyochan.kmpiap.openiap.SubResponseCodeAndroid +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class BillingResultMappingTest { + @Test + fun `maps every Play Billing 9_1 sub-response code`() { + assertEquals( + SubResponseCodeAndroid.NoApplicableSubResponseCode, + BillingClient.OnPurchasesUpdatedSubResponseCode.NO_APPLICABLE_SUB_RESPONSE_CODE + .toOpenIapSubResponseCode() + ) + assertEquals( + SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds, + BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS + .toOpenIapSubResponseCode() + ) + assertEquals( + SubResponseCodeAndroid.UserIneligible, + BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE + .toOpenIapSubResponseCode() + ) + } + + @Test + fun `unknown sub-response code remains absent`() { + assertNull(Int.MAX_VALUE.toOpenIapSubResponseCode()) + } +} diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/DeveloperProvidedBillingReflectionTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/DeveloperProvidedBillingReflectionTest.kt new file mode 100644 index 000000000..f6c15fa4a --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/DeveloperProvidedBillingReflectionTest.kt @@ -0,0 +1,67 @@ +package io.github.hyochan.kmpiap + +import io.github.hyochan.kmpiap.openiap.ProductType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DeveloperProvidedBillingReflectionTest { + @Test + fun `billing 8_3 details emit even without external transaction token`() { + val details = extractDeveloperProvidedBillingDetails( + Billing83LikeDetails( + linkUri = "https://example.com/checkout", + originalExternalTransactionId = "external-123", + products = listOf( + Billing83LikeProduct( + id = "monthly", + offerToken = "offer-123", + type = "subs" + ) + ) + ) + ) + + assertNull(details.externalTransactionToken) + assertEquals("https://example.com/checkout", details.linkUri) + assertEquals("external-123", details.originalExternalTransactionId) + assertEquals(1, details.products.size) + assertEquals("monthly", details.products.single().id) + assertEquals("offer-123", details.products.single().offerToken) + assertEquals(ProductType.Subs, details.products.single().type) + } + + @Test + fun `missing optional detail getters do not suppress available fields`() { + val details = extractDeveloperProvidedBillingDetails(TokenOnlyDetails("token-123")) + + assertEquals("token-123", details.externalTransactionToken) + assertNull(details.linkUri) + assertNull(details.originalExternalTransactionId) + assertEquals(emptyList(), details.products) + } +} + +internal class Billing83LikeDetails( + private val linkUri: String, + private val originalExternalTransactionId: String, + private val products: List +) { + fun getLinkUri(): String = linkUri + fun getOriginalExternalTransactionId(): String = originalExternalTransactionId + fun getProducts(): List = products +} + +internal class Billing83LikeProduct( + private val id: String, + private val offerToken: String, + private val type: String +) { + fun getId(): String = id + fun getOfferToken(): String = offerToken + fun getType(): String = type +} + +internal class TokenOnlyDetails(private val token: String) { + fun getExternalTransactionToken(): String = token +} diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/PurchaseTimestampTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/PurchaseTimestampTest.kt new file mode 100644 index 000000000..8e1259d4d --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/PurchaseTimestampTest.kt @@ -0,0 +1,16 @@ +package io.github.hyochan.kmpiap + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PurchaseTimestampTest { + @Test + fun `Play purchase timestamp remains epoch milliseconds`() { + val purchaseTimeMillis = 1_700_000_000_123L + + assertEquals( + purchaseTimeMillis.toDouble(), + purchaseTimeMillis.toOpenIapTransactionDate() + ) + } +} diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionReplacementResolutionTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionReplacementResolutionTest.kt new file mode 100644 index 000000000..27dbc516f --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionReplacementResolutionTest.kt @@ -0,0 +1,42 @@ +package io.github.hyochan.kmpiap + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SubscriptionReplacementResolutionTest { + @Test + fun `legacy replacement mode follows native Google precedence`() { + assertNull( + resolveLegacySubscriptionReplacementMode( + purchaseToken = null, + originalExternalTransactionId = "original-external-id", + replacementMode = null + ) + ) + assertEquals( + 5, + resolveLegacySubscriptionReplacementMode( + purchaseToken = "play-purchase-token", + originalExternalTransactionId = null, + replacementMode = null + ) + ) + assertEquals( + 3, + resolveLegacySubscriptionReplacementMode( + purchaseToken = null, + originalExternalTransactionId = "original-external-id", + replacementMode = 3 + ) + ) + assertNull( + resolveLegacySubscriptionReplacementMode( + purchaseToken = "play-purchase-token", + originalExternalTransactionId = null, + replacementMode = 3, + hasProductLevelReplacementParams = true + ) + ) + } +} diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/UnsupportedGoogleApisTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/UnsupportedGoogleApisTest.kt new file mode 100644 index 000000000..509adb902 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/UnsupportedGoogleApisTest.kt @@ -0,0 +1,29 @@ +package io.github.hyochan.kmpiap + +import io.github.hyochan.kmpiap.openiap.ErrorCode +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class UnsupportedGoogleApisTest { + @Test + fun `non-Play stores reject Google in-app messages`() = runTest { + for ((storeName, store) in listOf( + "amazon" to Store.AMAZON, + "horizon" to Store.PLAY_STORE, + )) { + val implementation = AmazonInAppPurchaseAndroid( + storeName = storeName, + store = store, + versionPlatform = "Android $storeName", + ) + + val error = assertFailsWith { + implementation.showInAppMessagesAndroid(null) + } + + assertEquals(ErrorCode.FeatureNotSupported, error.error.code) + } + } +} diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/dsl/PurchaseDsl.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/dsl/PurchaseDsl.kt index 7a23aaac2..3c8907bc0 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/dsl/PurchaseDsl.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/dsl/PurchaseDsl.kt @@ -14,6 +14,7 @@ import io.github.hyochan.kmpiap.openiap.RequestSubscriptionPropsByPlatforms import io.github.hyochan.kmpiap.openiap.DiscountOfferInputIOS import io.github.hyochan.kmpiap.openiap.PromotionalOfferJWSInputIOS import io.github.hyochan.kmpiap.openiap.SubscriptionBillingPlanTypeIOS +import io.github.hyochan.kmpiap.openiap.SubscriptionProductReplacementParamsAndroid import io.github.hyochan.kmpiap.openiap.WinBackOfferInputIOS /** @@ -201,8 +202,11 @@ class AndroidOptionsBuilder { var obfuscatedProfileId: String? = null var isOfferPersonalized: Boolean? = null var purchaseToken: String? = null + /** Original external transaction ID for a developer-billed subscription replacement (9.1.0+). */ + var originalExternalTransactionId: String? = null var replacementMode: Int? = null var subscriptionOffers: List = emptyList() + var subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = null /** * Offer token for one-time purchase discounts (Android 7.0+). * Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers @@ -210,9 +214,7 @@ class AndroidOptionsBuilder { */ var offerToken: String? = null /** - * Developer billing option for External Payments (Billing Library 8.3.0+, Japan only). - * When set, the purchase dialog shows side-by-side choice between Google Play - * and developer's external payment option. + * Developer billing option for External Payments (8.3.0+) or Billing Choice (9.1.0+). */ var developerBillingOption: DeveloperBillingOptionParamsAndroid? = null @@ -234,8 +236,10 @@ class AndroidOptionsBuilder { obfuscatedProfileId = obfuscatedProfileId, isOfferPersonalized = isOfferPersonalized, purchaseToken = purchaseToken, + originalExternalTransactionId = originalExternalTransactionId, replacementMode = replacementMode, subscriptionOffers = if (subscriptionOffers.isNotEmpty()) subscriptionOffers else null, + subscriptionProductReplacementParams = subscriptionProductReplacementParams, developerBillingOption = developerBillingOption ) 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 538203070..f9b40ea43 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 @@ -50,8 +50,76 @@ public enum class AlternativeBillingModeAndroid(val rawValue: String) { } /** - * Billing program types for external content links, external offers, and external payments (Android) - * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 + * Play Billing choice image layout (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class BillingChoiceImageLayoutAndroid(val rawValue: String) { + /** + * Rectangular image with a 4:1 aspect ratio. + */ + RectangularFourByOne("rectangular-four-by-one"), + /** + * Rectangular image with a 3:1 aspect ratio. + */ + RectangularThreeByOne("rectangular-three-by-one"), + /** + * Rectangular image with a 2:2 aspect ratio. + */ + RectangularTwoByTwo("rectangular-two-by-two"); + + companion object { + fun fromJson(value: String): BillingChoiceImageLayoutAndroid = when (value) { + "rectangular-four-by-one" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne + "RECTANGULAR_FOUR_BY_ONE" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne + "rectangular-three-by-one" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne + "RECTANGULAR_THREE_BY_ONE" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne + "rectangular-two-by-two" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo + "RECTANGULAR_TWO_BY_TWO" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo + else -> throw IllegalArgumentException("Unknown BillingChoiceImageLayoutAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Choice screen renderer for Billing Choice availability (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class BillingChoiceScreenTypeAndroid(val rawValue: String) { + /** + * Unspecified choice screen type. + */ + Unspecified("unspecified"), + /** + * Choice screen is rendered by the developer app. + */ + DeveloperRendered("developer-rendered"), + /** + * Choice screen is rendered by Google Play. + */ + GoogleRendered("google-rendered"); + + companion object { + fun fromJson(value: String): BillingChoiceScreenTypeAndroid = when (value) { + "unspecified" -> BillingChoiceScreenTypeAndroid.Unspecified + "UNSPECIFIED" -> BillingChoiceScreenTypeAndroid.Unspecified + "developer-rendered" -> BillingChoiceScreenTypeAndroid.DeveloperRendered + "DEVELOPER_RENDERED" -> BillingChoiceScreenTypeAndroid.DeveloperRendered + "google-rendered" -> BillingChoiceScreenTypeAndroid.GoogleRendered + "GOOGLE_RENDERED" -> BillingChoiceScreenTypeAndroid.GoogleRendered + else -> throw IllegalArgumentException("Unknown BillingChoiceScreenTypeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Billing program types for Google Play Billing Programs (Android) + * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, + * BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ public enum class BillingProgramAndroid(val rawValue: String) { /** @@ -82,7 +150,13 @@ public enum class BillingProgramAndroid(val rawValue: String) { * Users can choose to complete the purchase on the developer's website. * Available in Google Play Billing Library 8.3.0+ */ - ExternalPayments("external-payments"); + ExternalPayments("external-payments"), + /** + * Billing Choice program. + * Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + BillingChoice("billing-choice"); companion object { fun fromJson(value: String): BillingProgramAndroid = when (value) { @@ -96,6 +170,8 @@ public enum class BillingProgramAndroid(val rawValue: String) { "EXTERNAL_OFFER" -> BillingProgramAndroid.ExternalOffer "external-payments" -> BillingProgramAndroid.ExternalPayments "EXTERNAL_PAYMENTS" -> BillingProgramAndroid.ExternalPayments + "billing-choice" -> BillingProgramAndroid.BillingChoice + "BILLING_CHOICE" -> BillingProgramAndroid.BillingChoice else -> throw IllegalArgumentException("Unknown BillingProgramAndroid value: $value") } } @@ -139,6 +215,39 @@ public enum class DeveloperBillingLaunchModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Developer-provided billing destination type for Billing Program reporting details (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class DeveloperBillingTypeAndroid(val rawValue: String) { + /** + * Unspecified developer billing type. Do not use. + */ + DeveloperBillingTypeUnspecified("developer-billing-type-unspecified"), + /** + * Developer-provided billing via native in-app experience. + */ + InApp("in-app"), + /** + * Developer-provided billing via external link or embedded web browsing. + */ + ExternalLink("external-link"); + + companion object { + fun fromJson(value: String): DeveloperBillingTypeAndroid = when (value) { + "developer-billing-type-unspecified" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified + "DEVELOPER_BILLING_TYPE_UNSPECIFIED" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified + "in-app" -> DeveloperBillingTypeAndroid.InApp + "IN_APP" -> DeveloperBillingTypeAndroid.InApp + "external-link" -> DeveloperBillingTypeAndroid.ExternalLink + "EXTERNAL_LINK" -> DeveloperBillingTypeAndroid.ExternalLink + else -> throw IllegalArgumentException("Unknown DeveloperBillingTypeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + /** * Discount offer type enumeration. * Categorizes the type of discount or promotional offer. @@ -500,8 +609,9 @@ public enum class IapEvent(val rawValue: String) { PromotedProductIos("promoted-product-ios"), UserChoiceBillingAndroid("user-choice-billing-android"), /** - * Fired when user selects developer-provided billing option in external payments flow. - * Available on Android with Google Play Billing Library 8.3.0+ + * Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + * developer billing selections on Android. Billing Choice is available in + * OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ DeveloperProvidedBillingAndroid("developer-provided-billing-android"), /** @@ -656,6 +766,62 @@ public enum class IapStore(val rawValue: String) { fun toJson(): String = rawValue } +/** + * High-level in-app message category (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public enum class InAppMessageCategoryAndroid(val rawValue: String) { + /** + * Unknown in-app message category. + */ + UnknownInAppMessageCategoryId("unknown-in-app-message-category-id"), + /** + * Transactional billing messages, such as payment issues or pending price-change confirmations. + */ + Transactional("transactional"); + + companion object { + fun fromJson(value: String): InAppMessageCategoryAndroid = when (value) { + "unknown-in-app-message-category-id" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId + "UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId + "transactional" -> InAppMessageCategoryAndroid.Transactional + "TRANSACTIONAL" -> InAppMessageCategoryAndroid.Transactional + else -> throw IllegalArgumentException("Unknown InAppMessageCategoryAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Response code from Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public enum class InAppMessageResponseCodeAndroid(val rawValue: String) { + /** + * Flow finished and no developer action is needed. + */ + NoActionNeeded("no-action-needed"), + /** + * Subscription status changed and the purchase token should be checked. + */ + SubscriptionStatusUpdated("subscription-status-updated"); + + companion object { + fun fromJson(value: String): InAppMessageResponseCodeAndroid = when (value) { + "no-action-needed" -> InAppMessageResponseCodeAndroid.NoActionNeeded + "NO_ACTION_NEEDED" -> InAppMessageResponseCodeAndroid.NoActionNeeded + "subscription-status-updated" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + "SUBSCRIPTION_STATUS_UPDATED" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + else -> throw IllegalArgumentException("Unknown InAppMessageResponseCodeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + /** * Payment mode for subscription offers. * Determines how the user pays during the offer period. @@ -1416,6 +1582,9 @@ public interface PurchaseCommon { * Store where purchase was made */ val store: IapStore + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ val transactionDate: Double } @@ -1446,6 +1615,9 @@ public data class ActiveSubscription( * pending upgrades/downgrades, and auto-renewal preferences. */ val renewalInfoIOS: RenewalInfoIOS? = null, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ val transactionDate: Double, val transactionId: String, /** @@ -1704,6 +1876,37 @@ public data class AppTransaction( ) } +/** + * Display information for developer-rendered Billing Choice screens (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class BillingChoiceInfoAndroid( + /** + * URL for the Play Billing choice image matching the requested layout. + */ + val playBillingChoiceImageUrl: String, + /** + * Play Loyalty information for the user. + */ + val playBillingLoyaltyInfo: String? = null +) { + + companion object { + fun fromJson(json: Map): BillingChoiceInfoAndroid { + return BillingChoiceInfoAndroid( + playBillingChoiceImageUrl = json["playBillingChoiceImageUrl"] as? String ?: "", + playBillingLoyaltyInfo = json["playBillingLoyaltyInfo"] as? String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "BillingChoiceInfoAndroid", + "playBillingChoiceImageUrl" to playBillingChoiceImageUrl, + "playBillingLoyaltyInfo" to playBillingLoyaltyInfo, + ) +} + /** * Result of checking billing program availability (Android) * Available in Google Play Billing Library 8.2.0+ @@ -1713,17 +1916,30 @@ public data class BillingProgramAvailabilityResultAndroid( * The billing program that was checked */ val billingProgram: BillingProgramAndroid, + /** + * Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + val choiceScreenType: BillingChoiceScreenTypeAndroid? = null, /** * Whether the billing program is available for the user */ - val isAvailable: Boolean + val isAvailable: Boolean, + /** + * Whether external-link payment is available for Billing Choice. + * Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + val isExternalLinkAvailable: Boolean? = null ) { companion object { fun fromJson(json: Map): BillingProgramAvailabilityResultAndroid { return BillingProgramAvailabilityResultAndroid( billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified, + choiceScreenType = (json["choiceScreenType"] as? String)?.let { BillingChoiceScreenTypeAndroid.fromJson(it) }, isAvailable = json["isAvailable"] as? Boolean ?: false, + isExternalLinkAvailable = json["isExternalLinkAvailable"] as? Boolean, ) } } @@ -1731,7 +1947,9 @@ public data class BillingProgramAvailabilityResultAndroid( fun toJson(): Map = mapOf( "__typename" to "BillingProgramAvailabilityResultAndroid", "billingProgram" to billingProgram.toJson(), + "choiceScreenType" to choiceScreenType?.toJson(), "isAvailable" to isAvailable, + "isExternalLinkAvailable" to isExternalLinkAvailable, ) } @@ -1814,16 +2032,32 @@ public data class BillingResultAndroid( public data class DeveloperProvidedBillingDetailsAndroid( /** * External transaction token used to report transactions made through developer billing. - * This token must be used when reporting the external transaction to Google Play. - * Must be reported within 24 hours of the transaction. + * Nullable for flows such as external payments where no token is returned. */ - val externalTransactionToken: String + val externalTransactionToken: String? = null, + /** + * URI to launch for an external-link Billing Choice flow, when provided by + * Google Play. + */ + val linkUri: String? = null, + /** + * Original external transaction ID when replacing a subscription that was + * purchased through developer billing. + */ + val originalExternalTransactionId: String? = null, + /** + * Products selected for the developer billing flow. + */ + val products: List ) { companion object { fun fromJson(json: Map): DeveloperProvidedBillingDetailsAndroid { return DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken = json["externalTransactionToken"] as? String ?: "", + externalTransactionToken = json["externalTransactionToken"] as? String, + linkUri = json["linkUri"] as? String, + originalExternalTransactionId = json["originalExternalTransactionId"] as? String, + products = (json["products"] as? List<*>)?.mapNotNull { (it as? Map)?.let { DeveloperProvidedBillingProductAndroid.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for DeveloperProvidedBillingProductAndroid") } ?: emptyList(), ) } } @@ -1831,6 +2065,45 @@ public data class DeveloperProvidedBillingDetailsAndroid( fun toJson(): Map = mapOf( "__typename" to "DeveloperProvidedBillingDetailsAndroid", "externalTransactionToken" to externalTransactionToken, + "linkUri" to linkUri, + "originalExternalTransactionId" to originalExternalTransactionId, + "products" to products.map { it.toJson() }, + ) +} + +/** + * Product selected for developer-provided billing (Android 9.0+). + */ +public data class DeveloperProvidedBillingProductAndroid( + /** + * Product identifier. + */ + val id: String, + /** + * Subscription offer token, when applicable. + */ + val offerToken: String? = null, + /** + * Google Play product type (in-app or subscription). + */ + val type: ProductType +) { + + companion object { + fun fromJson(json: Map): DeveloperProvidedBillingProductAndroid { + return DeveloperProvidedBillingProductAndroid( + id = json["id"] as? String ?: "", + offerToken = json["offerToken"] as? String, + type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "DeveloperProvidedBillingProductAndroid", + "id" to id, + "offerToken" to offerToken, + "type" to type.toJson(), ) } @@ -2338,6 +2611,38 @@ public data class FetchProductsResultProducts(val value: List?) : Fetch public data class FetchProductsResultSubscriptions(val value: List?) : FetchProductsResult +/** + * Result from showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public data class InAppMessageResultAndroid( + /** + * Purchase token returned when a subscription status changed. + */ + val purchaseToken: String? = null, + /** + * Response code for the in-app messaging flow. + */ + val responseCode: InAppMessageResponseCodeAndroid +) { + + companion object { + fun fromJson(json: Map): InAppMessageResultAndroid { + return InAppMessageResultAndroid( + purchaseToken = json["purchaseToken"] as? String, + responseCode = (json["responseCode"] as? String)?.let { InAppMessageResponseCodeAndroid.fromJson(it) } ?: InAppMessageResponseCodeAndroid.NoActionNeeded, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "InAppMessageResultAndroid", + "purchaseToken" to purchaseToken, + "responseCode" to responseCode.toJson(), + ) +} + /** * Installment plan details for subscription offers (Android) * Contains information about the installment plan commitment. @@ -2584,7 +2889,7 @@ public data class ProductAndroid( oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, price = (json["price"] as? Number)?.toDouble(), - productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) }, + productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { runCatching { ProductStatusAndroid.fromJson(it) }.getOrNull() ?: ProductStatusAndroid.Unknown }, subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") }, subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") }, title = json["title"] as? String ?: "", @@ -2846,7 +3151,7 @@ public data class ProductSubscriptionAndroid( oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, price = (json["price"] as? Number)?.toDouble(), - productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) }, + productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { runCatching { ProductStatusAndroid.fromJson(it) }.getOrNull() ?: ProductStatusAndroid.Unknown }, subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") } ?: emptyList(), subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") } ?: emptyList(), title = json["title"] as? String ?: "", @@ -3067,6 +3372,9 @@ public data class PurchaseAndroid( * Store where purchase was made */ override val store: IapStore, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ override val transactionDate: Double, val transactionId: String? = null ) : PurchaseCommon, Purchase { @@ -3089,11 +3397,11 @@ public data class PurchaseAndroid( pendingPurchaseUpdateAndroid = (json["pendingPurchaseUpdateAndroid"] as? Map)?.let { PendingPurchaseUpdateAndroid.fromJson(it) }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, productId = json["productId"] as? String ?: "", - purchaseState = (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } ?: PurchaseState.Pending, + purchaseState = runCatching { (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } }.getOrNull() ?: PurchaseState.Unknown, purchaseToken = json["purchaseToken"] as? String, quantity = (json["quantity"] as? Number)?.toInt() ?: 0, signatureAndroid = json["signatureAndroid"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String, ) @@ -3141,7 +3449,7 @@ public data class PurchaseError( companion object { fun fromJson(json: Map): PurchaseError { return PurchaseError( - code = (json["code"] as? String)?.let { ErrorCode.fromJson(it) } ?: ErrorCode.Unknown, + code = runCatching { (json["code"] as? String)?.let { ErrorCode.fromJson(it) } }.getOrNull() ?: ErrorCode.Unknown, debugMessage = json["debugMessage"] as? String, isEmptyProductList = json["isEmptyProductList"] as? Boolean, message = json["message"] as? String ?: "", @@ -3214,6 +3522,9 @@ public data class PurchaseIOS( override val store: IapStore, val storefrontCountryCodeIOS: String? = null, val subscriptionGroupIdIOS: String? = null, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ override val transactionDate: Double, val transactionId: String, val transactionReasonIOS: String? = null, @@ -3226,7 +3537,7 @@ public data class PurchaseIOS( advancedCommerceInfoIOS = (json["advancedCommerceInfoIOS"] as? Map)?.let { AdvancedCommerceInfoIOS.fromJson(it) }, appAccountToken = json["appAccountToken"] as? String, appBundleIdIOS = json["appBundleIdIOS"] as? String, - billingPlanTypeIOS = (json["billingPlanTypeIOS"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) }, + billingPlanTypeIOS = (json["billingPlanTypeIOS"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown }, commitmentInfoIOS = (json["commitmentInfoIOS"] as? Map)?.let { TransactionCommitmentInfoIOS.fromJson(it) }, countryCodeIOS = json["countryCodeIOS"] as? String, currencyCodeIOS = json["currencyCodeIOS"] as? String, @@ -3244,7 +3555,7 @@ public data class PurchaseIOS( ownershipTypeIOS = json["ownershipTypeIOS"] as? String, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, productId = json["productId"] as? String ?: "", - purchaseState = (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } ?: PurchaseState.Pending, + purchaseState = runCatching { (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } }.getOrNull() ?: PurchaseState.Unknown, purchaseToken = json["purchaseToken"] as? String, quantity = (json["quantity"] as? Number)?.toInt() ?: 0, quantityIOS = (json["quantityIOS"] as? Number)?.toInt(), @@ -3253,7 +3564,7 @@ public data class PurchaseIOS( renewalInfoIOS = (json["renewalInfoIOS"] as? Map)?.let { RenewalInfoIOS.fromJson(it) }, revocationDateIOS = (json["revocationDateIOS"] as? Number)?.toDouble(), revocationReasonIOS = json["revocationReasonIOS"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, storefrontCountryCodeIOS = json["storefrontCountryCodeIOS"] as? String, subscriptionGroupIdIOS = json["subscriptionGroupIdIOS"] as? String, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, @@ -3364,7 +3675,7 @@ public data class RenewalCommitmentInfoIOS( return RenewalCommitmentInfoIOS( commitmentAutoRenewProductId = json["commitmentAutoRenewProductId"] as? String ?: "", commitmentAutoRenewStatus = json["commitmentAutoRenewStatus"] as? Boolean ?: false, - commitmentRenewalBillingPlanType = (json["commitmentRenewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } ?: SubscriptionBillingPlanTypeIOS.Unknown, + commitmentRenewalBillingPlanType = runCatching { (json["commitmentRenewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown, commitmentRenewalDate = (json["commitmentRenewalDate"] as? Number)?.toDouble() ?: 0.0, commitmentRenewalPrice = (json["commitmentRenewalPrice"] as? Number)?.toDouble() ?: 0.0, ) @@ -3450,7 +3761,7 @@ public data class RenewalInfoIOS( jsonRepresentation = json["jsonRepresentation"] as? String, pendingUpgradeProductId = json["pendingUpgradeProductId"] as? String, priceIncreaseStatus = json["priceIncreaseStatus"] as? String, - renewalBillingPlanType = (json["renewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) }, + renewalBillingPlanType = (json["renewalBillingPlanType"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown }, renewalDate = (json["renewalDate"] as? Number)?.toDouble(), renewalOfferId = json["renewalOfferId"] as? String, renewalOfferType = json["renewalOfferType"] as? String, @@ -3531,8 +3842,8 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { return RequestVerifyPurchaseWithIapkitResult( isValid = json["isValid"] as? Boolean ?: false, - state = (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } ?: IapkitPurchaseState.Entitled, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + state = runCatching { (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } }.getOrNull() ?: IapkitPurchaseState.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, ) } } @@ -3714,7 +4025,7 @@ public data class SubscriptionOffer( numberOfPeriodsIOS = (json["numberOfPeriodsIOS"] as? Number)?.toInt(), offerTagsAndroid = (json["offerTagsAndroid"] as? List<*>)?.mapNotNull { it as? String }, offerTokenAndroid = json["offerTokenAndroid"] as? String, - paymentMode = (json["paymentMode"] as? String)?.let { PaymentMode.fromJson(it) }, + paymentMode = (json["paymentMode"] as? String)?.let { runCatching { PaymentMode.fromJson(it) }.getOrNull() ?: PaymentMode.Unknown }, period = (json["period"] as? Map)?.let { SubscriptionPeriod.fromJson(it) }, periodCount = (json["periodCount"] as? Number)?.toInt(), price = (json["price"] as? Number)?.toDouble() ?: 0.0, @@ -3808,7 +4119,7 @@ public data class SubscriptionPeriod( companion object { fun fromJson(json: Map): SubscriptionPeriod { return SubscriptionPeriod( - unit = (json["unit"] as? String)?.let { SubscriptionPeriodUnit.fromJson(it) } ?: SubscriptionPeriodUnit.Day, + unit = runCatching { (json["unit"] as? String)?.let { SubscriptionPeriodUnit.fromJson(it) } }.getOrNull() ?: SubscriptionPeriodUnit.Unknown, value = (json["value"] as? Number)?.toInt() ?: 0, ) } @@ -3856,7 +4167,7 @@ public data class SubscriptionPricingTermsIOS( return SubscriptionPricingTermsIOS( billingDisplayPrice = json["billingDisplayPrice"] as? String ?: "", billingPeriod = (json["billingPeriod"] as? Map)?.let { SubscriptionPeriodValueIOS.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionPeriodValueIOS"), - billingPlanType = (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } ?: SubscriptionBillingPlanTypeIOS.Unknown, + billingPlanType = runCatching { (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown, billingPrice = (json["billingPrice"] as? Number)?.toDouble() ?: 0.0, commitmentInfo = (json["commitmentInfo"] as? Map)?.let { SubscriptionCommitmentInfoIOS.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionCommitmentInfoIOS"), subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") }, @@ -4264,7 +4575,7 @@ public data class WebhookEvent( receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, renewsAt = (json["renewsAt"] as? Number)?.toDouble(), source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, - subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + subscriptionState = (json["subscriptionState"] as? String)?.let { runCatching { SubscriptionState.fromJson(it) }.getOrNull() ?: SubscriptionState.Unknown }, type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, ) } @@ -4322,6 +4633,38 @@ public data class AndroidSubscriptionOfferInput( ) } +/** + * Parameters for showing a billing program information dialog (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class BillingProgramInformationDialogParamsAndroid( + /** + * Billing program. Currently only BILLING_CHOICE is supported. + */ + val billingProgram: BillingProgramAndroid = BillingProgramAndroid.BillingChoice, + /** + * External transaction token returned by the Billing Choice reporting-details flow. + */ + val externalTransactionToken: String +) { + companion object { + fun fromJson(json: Map): BillingProgramInformationDialogParamsAndroid? { + val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.BillingChoice + val externalTransactionToken = json["externalTransactionToken"] as? String + if (externalTransactionToken == null) return null + return BillingProgramInformationDialogParamsAndroid( + billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, + ) + } + } + + fun toJson(): Map = mapOf( + "billingProgram" to billingProgram.toJson(), + "externalTransactionToken" to externalTransactionToken, + ) +} + public data class DeepLinkOptions( /** * Android package name to target (required on Android) @@ -4348,32 +4691,42 @@ public data class DeepLinkOptions( } /** - * Parameters for developer billing option in purchase flow (Android) - * Used with BillingFlowParams to enable external payments flow - * Available in Google Play Billing Library 8.3.0+ + * Parameters for a developer billing option in a purchase flow (Android). + * Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). + * Only billingProgram is required; link fields are used when the selected program + * links outside the app. */ public data class DeveloperBillingOptionParamsAndroid( /** - * The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + * The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. */ val billingProgram: BillingProgramAndroid, /** - * The launch mode for the external payment link + * A pre-generated external transaction token for a Billing Choice external-link + * flow. Omit it when Google Play should provide the token in the callback. */ - val launchMode: DeveloperBillingLaunchModeAndroid, + val externalTransactionToken: String? = null, /** - * The URI where the external payment will be processed + * The launch mode for the external payment link. + * Required only when the selected billing program links outside the app. */ - val linkUri: String + val launchMode: DeveloperBillingLaunchModeAndroid? = null, + /** + * The URI where the external payment will be processed. + * Required only when the selected billing program links outside the app. + */ + val linkUri: String? = null ) { companion object { fun fromJson(json: Map): DeveloperBillingOptionParamsAndroid? { val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified - val launchMode = (json["launchMode"] as? String)?.let { DeveloperBillingLaunchModeAndroid.fromJson(it) } ?: DeveloperBillingLaunchModeAndroid.Unspecified + val externalTransactionToken = json["externalTransactionToken"] as? String + val launchMode = (json["launchMode"] as? String)?.let { DeveloperBillingLaunchModeAndroid.fromJson(it) } val linkUri = json["linkUri"] as? String - if (linkUri == null) return null return DeveloperBillingOptionParamsAndroid( billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, launchMode = launchMode, linkUri = linkUri, ) @@ -4382,7 +4735,8 @@ public data class DeveloperBillingOptionParamsAndroid( fun toJson(): Map = mapOf( "billingProgram" to billingProgram.toJson(), - "launchMode" to launchMode.toJson(), + "externalTransactionToken" to externalTransactionToken, + "launchMode" to launchMode?.toJson(), "linkUri" to linkUri, ) } @@ -4436,6 +4790,65 @@ public data class DiscountOfferInputIOS( ) } +/** + * Parameters for fetching Billing Choice display information (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class GetBillingChoiceInfoParamsAndroid( + /** + * Billing program. Currently only BILLING_CHOICE is supported. + */ + val billingProgram: BillingProgramAndroid = BillingProgramAndroid.BillingChoice, + /** + * Desired Play Billing choice image layout. + */ + val playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + /** + * BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + */ + val userLocale: String? = null +) { + companion object { + fun fromJson(json: Map): GetBillingChoiceInfoParamsAndroid { + return GetBillingChoiceInfoParamsAndroid( + billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout = (json["playBillingChoiceImageLayout"] as? String)?.let { BillingChoiceImageLayoutAndroid.fromJson(it) } ?: BillingChoiceImageLayoutAndroid.RectangularFourByOne, + userLocale = json["userLocale"] as? String, + ) + } + } + + fun toJson(): Map = mapOf( + "billingProgram" to billingProgram.toJson(), + "playBillingChoiceImageLayout" to playBillingChoiceImageLayout.toJson(), + "userLocale" to userLocale, + ) +} + +/** + * Parameters for showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public data class InAppMessageParamsAndroid( + /** + * In-app message categories to show. Defaults to transactional messages. + */ + val categories: List? = listOf(InAppMessageCategoryAndroid.Transactional) +) { + companion object { + fun fromJson(json: Map): InAppMessageParamsAndroid { + return InAppMessageParamsAndroid( + categories = (json["categories"] as? List<*>)?.mapNotNull { runCatching { (it as? String)?.let { InAppMessageCategoryAndroid.fromJson(it) } }.getOrNull() ?: InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId } ?: listOf(InAppMessageCategoryAndroid.Transactional), + ) + } + } + + fun toJson(): Map = mapOf( + "categories" to categories?.map { it.toJson() }, + ) +} + /** * Connection initialization configuration */ @@ -4447,6 +4860,16 @@ public data class InitConnectionConfig( * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. */ val alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = null, + /** + * Billing Choice renderer configured in Play Console. Available in OpenIAP + * Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + * can emit the selection event. DEVELOPER_RENDERED omits that listener so the + * app can render its own choice screen and use the reporting/dialog/link APIs. + * Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + * Defaults to GOOGLE_RENDERED. + */ + val billingChoiceScreenTypeAndroid: BillingChoiceScreenTypeAndroid? = BillingChoiceScreenTypeAndroid.GoogleRendered, /** * Enable a specific billing program for Android (7.0+) * When set, enables the specified billing program for external transactions. @@ -4454,6 +4877,8 @@ public data class InitConnectionConfig( * - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) * - EXTERNAL_OFFER: External offers for digital content (8.2.0+) * - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + * - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) */ val enableBillingProgramAndroid: BillingProgramAndroid? = null ) { @@ -4461,6 +4886,7 @@ public data class InitConnectionConfig( fun fromJson(json: Map): InitConnectionConfig { return InitConnectionConfig( alternativeBillingModeAndroid = (json["alternativeBillingModeAndroid"] as? String)?.let { AlternativeBillingModeAndroid.fromJson(it) }, + billingChoiceScreenTypeAndroid = (json["billingChoiceScreenTypeAndroid"] as? String)?.let { BillingChoiceScreenTypeAndroid.fromJson(it) } ?: BillingChoiceScreenTypeAndroid.GoogleRendered, enableBillingProgramAndroid = (json["enableBillingProgramAndroid"] as? String)?.let { BillingProgramAndroid.fromJson(it) }, ) } @@ -4468,20 +4894,28 @@ public data class InitConnectionConfig( fun toJson(): Map = mapOf( "alternativeBillingModeAndroid" to alternativeBillingModeAndroid?.toJson(), + "billingChoiceScreenTypeAndroid" to billingChoiceScreenTypeAndroid?.toJson(), "enableBillingProgramAndroid" to enableBillingProgramAndroid?.toJson(), ) } /** * Parameters for launching an external link (Android) - * Used with launchExternalLink to initiate external offer or app install flows + * Used with launchExternalLink to initiate external offer, app install, or + * developer-rendered Billing Choice flows * Available in Google Play Billing Library 8.2.0+ */ public data class LaunchExternalLinkParamsAndroid( /** - * The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) */ val billingProgram: BillingProgramAndroid, + /** + * External transaction token for a developer-rendered Billing Choice external-link + * flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + */ + val externalTransactionToken: String? = null, /** * The external link launch mode */ @@ -4498,12 +4932,14 @@ public data class LaunchExternalLinkParamsAndroid( companion object { fun fromJson(json: Map): LaunchExternalLinkParamsAndroid? { val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified + val externalTransactionToken = json["externalTransactionToken"] as? String val launchMode = (json["launchMode"] as? String)?.let { ExternalLinkLaunchModeAndroid.fromJson(it) } ?: ExternalLinkLaunchModeAndroid.Unspecified val linkType = (json["linkType"] as? String)?.let { ExternalLinkTypeAndroid.fromJson(it) } ?: ExternalLinkTypeAndroid.Unspecified val linkUri = json["linkUri"] as? String if (linkUri == null) return null return LaunchExternalLinkParamsAndroid( billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, launchMode = launchMode, linkType = linkType, linkUri = linkUri, @@ -4513,6 +4949,7 @@ public data class LaunchExternalLinkParamsAndroid( fun toJson(): Map = mapOf( "billingProgram" to billingProgram.toJson(), + "externalTransactionToken" to externalTransactionToken, "launchMode" to launchMode.toJson(), "linkType" to linkType.toJson(), "linkUri" to linkUri, @@ -4521,12 +4958,12 @@ public data class LaunchExternalLinkParamsAndroid( public data class ProductRequest( val skus: List, - val type: ProductQueryType? = null + val type: ProductQueryType? = ProductQueryType.InApp ) { companion object { fun fromJson(json: Map): ProductRequest? { val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String } - val type = (json["type"] as? String)?.let { ProductQueryType.fromJson(it) } + val type = (json["type"] as? String)?.let { ProductQueryType.fromJson(it) } ?: ProductQueryType.InApp if (skus == null) return null return ProductRequest( skus = skus, @@ -4636,9 +5073,9 @@ public data class PurchaseUpdatedListenerOptions( public data class RequestPurchaseAndroidProps( /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null, /** @@ -4856,9 +5293,9 @@ public data class RequestPurchasePropsByPlatforms( public data class RequestSubscriptionAndroidProps( /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null, /** @@ -4874,6 +5311,12 @@ public data class RequestSubscriptionAndroidProps( * Obfuscated profile ID */ val obfuscatedProfileId: String? = null, + /** + * Original external transaction ID for replacing a subscription that was + * purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + val originalExternalTransactionId: String? = null, /** * Purchase token for upgrades/downgrades */ @@ -4903,6 +5346,7 @@ public data class RequestSubscriptionAndroidProps( val isOfferPersonalized = json["isOfferPersonalized"] as? Boolean val obfuscatedAccountId = json["obfuscatedAccountId"] as? String val obfuscatedProfileId = json["obfuscatedProfileId"] as? String + val originalExternalTransactionId = json["originalExternalTransactionId"] as? String val purchaseToken = json["purchaseToken"] as? String val replacementMode = (json["replacementMode"] as? Number)?.toInt() val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String } @@ -4914,6 +5358,7 @@ public data class RequestSubscriptionAndroidProps( isOfferPersonalized = isOfferPersonalized, obfuscatedAccountId = obfuscatedAccountId, obfuscatedProfileId = obfuscatedProfileId, + originalExternalTransactionId = originalExternalTransactionId, purchaseToken = purchaseToken, replacementMode = replacementMode, skus = skus, @@ -4928,6 +5373,7 @@ public data class RequestSubscriptionAndroidProps( "isOfferPersonalized" to isOfferPersonalized, "obfuscatedAccountId" to obfuscatedAccountId, "obfuscatedProfileId" to obfuscatedProfileId, + "originalExternalTransactionId" to originalExternalTransactionId, "purchaseToken" to purchaseToken, "replacementMode" to replacementMode, "skus" to skus, @@ -4984,7 +5430,7 @@ public data class RequestSubscriptionIosProps( val advancedCommerceData = json["advancedCommerceData"] as? String val andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as? Boolean val appAccountToken = json["appAccountToken"] as? String - val billingPlanType = (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } + val billingPlanType = (json["billingPlanType"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown } val compactJWS = json["compactJWS"] as? String val promotionalOfferJWS = (json["promotionalOfferJWS"] as? Map)?.let { PromotionalOfferJWSInputIOS.fromJson(it) } val quantity = (json["quantity"] as? Number)?.toInt() @@ -5206,7 +5652,7 @@ public data class SubscriptionProductReplacementParamsAndroid( companion object { fun fromJson(json: Map): SubscriptionProductReplacementParamsAndroid? { val oldProductId = json["oldProductId"] as? String - val replacementMode = (json["replacementMode"] as? String)?.let { SubscriptionReplacementModeAndroid.fromJson(it) } ?: SubscriptionReplacementModeAndroid.UnknownReplacementMode + val replacementMode = runCatching { (json["replacementMode"] as? String)?.let { SubscriptionReplacementModeAndroid.fromJson(it) } }.getOrNull() ?: SubscriptionReplacementModeAndroid.UnknownReplacementMode if (oldProductId == null) return null return SubscriptionProductReplacementParamsAndroid( oldProductId = oldProductId, @@ -5533,7 +5979,6 @@ public interface MutationResolver { suspend fun beginRefundRequestIOS(sku: String): String? /** * Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -5553,7 +5998,6 @@ public interface MutationResolver { * Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. * Must be called AFTER successful payment in your payment system. * Token must be reported to Google Play backend within 24 hours. - * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -5562,12 +6006,15 @@ public interface MutationResolver { /** * Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). * Replaces the deprecated createExternalOfferReportingDetailsAsync API. - * * Returns external transaction token needed for reporting external transactions. + * developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + * native Android defaults it to IN_APP. + * The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ - suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid + suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid? = null): BillingProgramReportingDetailsAndroid /** * Open the platform's subscription management UI. * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions @@ -5591,7 +6038,6 @@ public interface MutationResolver { /** * Check whether a billing program (e.g., External Payments) is available for the current user. * Replaces the deprecated isExternalOfferAvailableAsync API. - * * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. @@ -5599,9 +6045,11 @@ public interface MutationResolver { */ suspend fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid /** - * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + * including developer-rendered Billing Choice external-link flows. + * Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). * Replaces the deprecated showExternalOfferInformationDialog API. - * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -5646,12 +6094,19 @@ public interface MutationResolver { /** * Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. * Must be called BEFORE processing payment in your payment system. - * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ suspend fun showAlternativeBillingDialogAndroid(): Boolean + /** + * Show Google's mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + */ + suspend fun showBillingProgramInformationDialogAndroid(params: BillingProgramInformationDialogParamsAndroid): BillingResultAndroid /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. @@ -5659,6 +6114,15 @@ public interface MutationResolver { * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS + /** + * Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + * Returns a response code and, when the subscription status changes, the related purchase token. + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + */ + suspend fun showInAppMessagesAndroid(params: InAppMessageParamsAndroid? = null): InAppMessageResultAndroid /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios @@ -5735,6 +6199,13 @@ public interface QueryResolver { * See: https://openiap.dev/docs/apis/get-available-purchases */ suspend fun getAvailablePurchases(options: PurchaseOptions? = null): List + /** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client is not ready. + * See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + */ + suspend fun getBillingChoiceInfoAndroid(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. @@ -5816,11 +6287,11 @@ public interface QueryResolver { */ public interface SubscriptionResolver { /** - * Fires when a user selects developer billing in the External Payments flow (Android only) - * Triggered when the user chooses to pay via the developer's external payment option - * instead of Google Play Billing in the side-by-side choice dialog. - * Contains the externalTransactionToken needed to report the transaction. - * Available in Google Play Billing Library 8.3.0+ + * Fires when a user selects developer billing in an External Payments or + * Billing Choice flow (Android only). The payload can contain an external + * transaction token, link URI, original transaction ID, and selected products. + * Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ suspend fun developerProvidedBillingAndroid(): DeveloperProvidedBillingDetailsAndroid /** @@ -5869,7 +6340,7 @@ public typealias MutationCheckAlternativeBillingAvailabilityAndroidHandler = sus public typealias MutationClearTransactionIOSHandler = suspend () -> Boolean public typealias MutationConsumePurchaseAndroidHandler = suspend (purchaseToken: String) -> Boolean public typealias MutationCreateAlternativeBillingTokenAndroidHandler = suspend () -> String? -public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = suspend (program: BillingProgramAndroid) -> BillingProgramReportingDetailsAndroid +public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = suspend (program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid?) -> BillingProgramReportingDetailsAndroid public typealias MutationDeepLinkToSubscriptionsHandler = suspend (options: DeepLinkOptions?) -> Unit public typealias MutationEndConnectionHandler = suspend () -> Boolean public typealias MutationFinishTransactionHandler = suspend (purchase: PurchaseInput, isConsumable: Boolean?) -> Unit @@ -5883,7 +6354,9 @@ public typealias MutationRequestPurchaseHandler = suspend (params: RequestPurcha public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = suspend () -> Boolean public typealias MutationRestorePurchasesHandler = suspend () -> Unit public typealias MutationShowAlternativeBillingDialogAndroidHandler = suspend () -> Boolean +public typealias MutationShowBillingProgramInformationDialogAndroidHandler = suspend (params: BillingProgramInformationDialogParamsAndroid) -> BillingResultAndroid public typealias MutationShowExternalPurchaseCustomLinkNoticeIOSHandler = suspend (noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) -> ExternalPurchaseCustomLinkNoticeResultIOS +public typealias MutationShowInAppMessagesAndroidHandler = suspend (params: InAppMessageParamsAndroid?) -> InAppMessageResultAndroid public typealias MutationShowManageSubscriptionsIOSHandler = suspend () -> List public typealias MutationSyncIOSHandler = suspend () -> Boolean public typealias MutationValidateReceiptHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResult @@ -5911,7 +6384,9 @@ public data class MutationHandlers( val requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = null, val restorePurchases: MutationRestorePurchasesHandler? = null, val showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = null, + val showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidHandler? = null, val showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? = null, + val showInAppMessagesAndroid: MutationShowInAppMessagesAndroidHandler? = null, val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null, val syncIOS: MutationSyncIOSHandler? = null, val validateReceipt: MutationValidateReceiptHandler? = null, @@ -5928,6 +6403,7 @@ public typealias QueryGetActiveSubscriptionsHandler = suspend (subscriptionIds: public typealias QueryGetAllTransactionsIOSHandler = suspend () -> List public typealias QueryGetAppTransactionIOSHandler = suspend () -> AppTransaction? public typealias QueryGetAvailablePurchasesHandler = suspend (options: PurchaseOptions?) -> List +public typealias QueryGetBillingChoiceInfoAndroidHandler = suspend (params: GetBillingChoiceInfoParamsAndroid) -> BillingChoiceInfoAndroid public typealias QueryGetExternalPurchaseCustomLinkTokenIOSHandler = suspend (tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) -> ExternalPurchaseCustomLinkTokenResultIOS public typealias QueryGetPendingTransactionsIOSHandler = suspend () -> List public typealias QueryGetPromotedProductIOSHandler = suspend () -> ProductIOS? @@ -5951,6 +6427,7 @@ public data class QueryHandlers( val getAllTransactionsIOS: QueryGetAllTransactionsIOSHandler? = null, val getAppTransactionIOS: QueryGetAppTransactionIOSHandler? = null, val getAvailablePurchases: QueryGetAvailablePurchasesHandler? = null, + val getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidHandler? = null, val getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIOSHandler? = null, val getPendingTransactionsIOS: QueryGetPendingTransactionsIOSHandler? = null, val getPromotedProductIOS: QueryGetPromotedProductIOSHandler? = null, diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt index dde4d862a..cbb852abb 100644 --- a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt @@ -2,6 +2,7 @@ package io.github.hyochan.kmpiap +import io.github.hyochan.kmpiap.dsl.AndroidOptionsBuilder import io.github.hyochan.kmpiap.openiap.* import kotlin.test.Test import kotlin.test.assertEquals @@ -807,19 +808,87 @@ class InAppPurchaseTest { assertEquals("https://example.com/payment", params.linkUri) } + @Test + fun testDeveloperBillingOptionParamsAndroidWithInAppBillingChoice() { + val params = DeveloperBillingOptionParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice + ) + + assertNull(params.externalTransactionToken) + assertNull(params.launchMode) + assertNull(params.linkUri) + } + + @Test + fun testBillingChoiceConnectionConfigRenderer() { + assertEquals( + BillingChoiceScreenTypeAndroid.GoogleRendered, + InitConnectionConfig().billingChoiceScreenTypeAndroid + ) + assertEquals( + BillingChoiceScreenTypeAndroid.DeveloperRendered, + InitConnectionConfig( + billingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.DeveloperRendered, + enableBillingProgramAndroid = BillingProgramAndroid.BillingChoice + ).billingChoiceScreenTypeAndroid + ) + } + + @Test + fun testBillingChoiceLaunchExternalLinkToken() { + val params = LaunchExternalLinkParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice, + externalTransactionToken = "external-token", + launchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, + linkType = ExternalLinkTypeAndroid.LinkToDigitalContentOffer, + linkUri = "https://example.com/checkout" + ) + + assertEquals("external-token", params.externalTransactionToken) + } + + @Test + fun testAndroidOptionsBuilderPreservesExternalSubscriptionId() { + val options = AndroidOptionsBuilder().apply { + skus = listOf("monthly_subscription") + originalExternalTransactionId = "original-external-id" + developerBillingOption = DeveloperBillingOptionParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice + ) + }.build() + + assertEquals( + "original-external-id", + options?.subscription?.originalExternalTransactionId + ) + } + @Test fun testDeveloperProvidedBillingDetailsAndroid() { val details = DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken = "ext_txn_token_12345" + externalTransactionToken = null, + linkUri = "https://example.com/checkout", + originalExternalTransactionId = "original-external-id", + products = listOf( + DeveloperProvidedBillingProductAndroid( + id = "monthly_subscription", + offerToken = "offer-token", + type = ProductType.Subs + ) + ) ) - assertEquals("ext_txn_token_12345", details.externalTransactionToken) + assertNull(details.externalTransactionToken) + assertEquals("https://example.com/checkout", details.linkUri) + assertEquals("original-external-id", details.originalExternalTransactionId) + assertEquals(ProductType.Subs, details.products.single().type) } @Test fun testRequestPurchaseAndroidPropsWithDeveloperBillingOption() { val developerBillingOption = DeveloperBillingOptionParamsAndroid( - billingProgram = BillingProgramAndroid.ExternalPayments, + billingProgram = BillingProgramAndroid.BillingChoice, + externalTransactionToken = "pre-generated-token", launchMode = DeveloperBillingLaunchModeAndroid.LaunchInExternalBrowserOrApp, linkUri = "https://example.com/checkout" ) @@ -831,19 +900,22 @@ class InAppPurchaseTest { assertEquals(listOf("premium_product"), props.skus) assertNotNull(props.developerBillingOption) - assertEquals(BillingProgramAndroid.ExternalPayments, props.developerBillingOption?.billingProgram) + assertEquals(BillingProgramAndroid.BillingChoice, props.developerBillingOption?.billingProgram) + assertEquals("pre-generated-token", props.developerBillingOption?.externalTransactionToken) assertEquals("https://example.com/checkout", props.developerBillingOption?.linkUri) } @Test fun testRequestSubscriptionAndroidPropsWithDeveloperBillingOption() { val developerBillingOption = DeveloperBillingOptionParamsAndroid( - billingProgram = BillingProgramAndroid.ExternalPayments, + billingProgram = BillingProgramAndroid.BillingChoice, + externalTransactionToken = "pre-generated-token", launchMode = DeveloperBillingLaunchModeAndroid.LaunchInExternalBrowserOrApp, linkUri = "https://example.com/subscribe" ) val props = RequestSubscriptionAndroidProps( + originalExternalTransactionId = "original-external-id", skus = listOf("monthly_subscription"), subscriptionOffers = listOf( AndroidSubscriptionOfferInput( @@ -856,7 +928,9 @@ class InAppPurchaseTest { assertEquals(listOf("monthly_subscription"), props.skus) assertNotNull(props.developerBillingOption) - assertEquals(BillingProgramAndroid.ExternalPayments, props.developerBillingOption?.billingProgram) + assertEquals("original-external-id", props.originalExternalTransactionId) + assertEquals(BillingProgramAndroid.BillingChoice, props.developerBillingOption?.billingProgram) + assertEquals("pre-generated-token", props.developerBillingOption?.externalTransactionToken) assertEquals("https://example.com/subscribe", props.developerBillingOption?.linkUri) } 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 9f6cb81a4..de13a2eb2 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 @@ -1657,10 +1657,25 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { * * @see https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ - override suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid { + override suspend fun getBillingChoiceInfoAndroid(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid { + throw UnsupportedOperationException("getBillingChoiceInfoAndroid is only available on Android") + } + + override suspend fun createBillingProgramReportingDetailsAndroid( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? + ): BillingProgramReportingDetailsAndroid { throw UnsupportedOperationException("createBillingProgramReportingDetailsAndroid is only available on Android") } + override suspend fun showBillingProgramInformationDialogAndroid(params: BillingProgramInformationDialogParamsAndroid): BillingResultAndroid { + throw UnsupportedOperationException("showBillingProgramInformationDialogAndroid is only available on Android") + } + + override suspend fun showInAppMessagesAndroid(params: InAppMessageParamsAndroid?): InAppMessageResultAndroid { + throw UnsupportedOperationException("showInAppMessagesAndroid is only available on Android") + } + /** * Launch an external content/offer link (Play Billing 8.2.0+). * diff --git a/libraries/kmp-iap/serializeTransaction_enhanced.swift b/libraries/kmp-iap/serializeTransaction_enhanced.swift index a3f3bc04c..d749e5f7f 100644 --- a/libraries/kmp-iap/serializeTransaction_enhanced.swift +++ b/libraries/kmp-iap/serializeTransaction_enhanced.swift @@ -32,7 +32,7 @@ func serializeTransaction(_ transaction: Transaction, jwsRepresentationIOS: Stri "productId": transaction.productID, "ids": [transaction.productID], "transactionId": String(transaction.id), // @deprecated - use id instead - "transactionDate": transaction.purchaseDate.timeIntervalSince1970, + "transactionDate": transaction.purchaseDate.timeIntervalSince1970 * 1000, "transactionReceipt": jwsReceipt, "platform": "ios", // Existing iOS-specific fields @@ -115,4 +115,4 @@ func serializeTransaction(_ transaction: Transaction, jwsRepresentationIOS: Stri } return purchaseMap -} \ No newline at end of file +} diff --git a/libraries/maui-iap/Directory.Build.props b/libraries/maui-iap/Directory.Build.props new file mode 100644 index 000000000..ef7fd1494 --- /dev/null +++ b/libraries/maui-iap/Directory.Build.props @@ -0,0 +1,12 @@ + + + $(DefaultItemExcludes);obj/**;bin/** + + + + + obj/stores/$(OpenIapAndroidStore)/ + bin/stores/$(OpenIapAndroidStore)/ + + diff --git a/libraries/maui-iap/README.md b/libraries/maui-iap/README.md index 9e3b1e80b..63b3a904e 100644 --- a/libraries/maui-iap/README.md +++ b/libraries/maui-iap/README.md @@ -141,6 +141,24 @@ For .NET 10 apps, use the matching `net10.0-android`, `net10.0-ios`, and VS Code launch configurations are in `libraries/maui-iap/.vscode/launch.json`. The Android launcher builds both AARs before compiling the example app. +### Android store variants + +Source builds can select Amazon Appstore or Meta Horizon instead of Google +Play. Build the matching native facade immediately before the .NET build: + +```bash +cd libraries/maui-iap/android +../../../packages/google/gradlew :openiap:assembleRelease -PopenIapAndroidStore=amazon +cd .. +dotnet build example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj \ + -f net9.0-android \ + -p:OpenIapAndroidStore=amazon +``` + +Use `horizon` for Meta Horizon and `play` for Google Play. MAUI keeps each +store's intermediate and output directories separate, preventing a prior +store build from leaking its AAR or manifest into the next variant. + ## What's generated vs. hand-written - **Generated:** [`src/OpenIap.Maui/Types.cs`](src/OpenIap.Maui/Types.cs) is 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 38c5c013b..df0f4e3b4 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 @@ -6,13 +6,17 @@ import android.app.Activity import android.content.Context import com.google.gson.Gson import dev.hyo.openiap.AlternativeBillingMode +import dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid import dev.hyo.openiap.BillingProgramAndroid import dev.hyo.openiap.DeepLinkOptions +import dev.hyo.openiap.DeveloperBillingTypeAndroid 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 +import dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid +import dev.hyo.openiap.InAppMessageParamsAndroid import dev.hyo.openiap.LaunchExternalLinkParamsAndroid import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapModule @@ -217,9 +221,36 @@ class OpenIapMauiModule(context: Context) { gson.toJson(module.isBillingProgramAvailable(program).toJson()) } - fun createBillingProgramReportingDetailsAndroid(programJson: String, callback: ResultCallback) = run(callback) { + fun getBillingChoiceInfoAndroid(paramsJson: String, callback: ResultCallback) = run(callback) { + val params = GetBillingChoiceInfoParamsAndroid.fromJson(parseMap(paramsJson)) + gson.toJson(module.getBillingChoiceInfo(params).toJson()) + } + + fun createBillingProgramReportingDetailsAndroid(programJson: String, callback: ResultCallback) = + createBillingProgramReportingDetailsAndroidWithType(programJson, null, callback) + + fun createBillingProgramReportingDetailsAndroidWithType( + programJson: String, + developerBillingTypeJson: String?, + callback: ResultCallback + ) = run(callback) { val program = parseProgram(programJson) - gson.toJson(module.createBillingProgramReportingDetails(program).toJson()) + val developerBillingType = + developerBillingTypeJson?.let { DeveloperBillingTypeAndroid.fromJson(it) } + gson.toJson(module.createBillingProgramReportingDetails(program, developerBillingType).toJson()) + } + + fun showBillingProgramInformationDialogAndroid(paramsJson: String, callback: ResultCallback) = run(callback) { + val params = BillingProgramInformationDialogParamsAndroid.fromJson(parseMap(paramsJson)) + ?: throw badInput("BillingProgramInformationDialogParamsAndroid") + val activity = currentActivityOrThrow("showBillingProgramInformationDialogAndroid") + gson.toJson(module.showBillingProgramInformationDialog(activity, params).toJson()) + } + + fun showInAppMessagesAndroid(paramsJson: String?, callback: ResultCallback) = run(callback) { + val params = paramsJson?.let { InAppMessageParamsAndroid.fromJson(parseMap(it)) } + val activity = currentActivityOrThrow("showInAppMessagesAndroid") + gson.toJson(module.showInAppMessages(activity, params).toJson()) } fun launchExternalLinkAndroid(paramsJson: String, callback: ResultCallback) = run(callback) { diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/AlternativeBillingPage.xaml.cs b/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/AlternativeBillingPage.xaml.cs index da8db5dae..d82d0bf84 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/AlternativeBillingPage.xaml.cs +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/AlternativeBillingPage.xaml.cs @@ -397,8 +397,12 @@ await mutate.RequestPurchaseAsync(new RequestPurchaseProps } catch (Exception ex) { - ShowResult($"❌ Error: {ErrorUtils.ExtractErrorMessage(ex)}"); - await DisplayAlert("Error", ErrorUtils.ExtractErrorMessage(ex), "OK"); + var message = ErrorUtils.FormatPurchaseFailure(ex); + ShowResult(ErrorUtils.IsUserCancelled(ex) ? $"⚠️ {message}" : $"❌ {message}"); + if (!ErrorUtils.IsUserCancelled(ex)) + { + await DisplayAlert("Error", ErrorUtils.ExtractErrorMessage(ex), "OK"); + } SetProcessing(false); } } @@ -431,9 +435,13 @@ private void OnPurchaseError(PurchaseError error) { _isProcessing = false; UpdatePurchaseButton(); - ShowResult($"❌ Purchase failed: {error.Message}"); - if (error.Code != ErrorCode.UserCancelled) + if (error.Code == ErrorCode.UserCancelled) + { + ShowResult("⚠️ Purchase cancelled by user"); + } + else { + ShowResult($"❌ Purchase failed: {error.Message}"); _ = DisplayAlert("Error", error.Message, "OK"); } } diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/PurchaseFlowPage.xaml.cs b/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/PurchaseFlowPage.xaml.cs index af7d5a937..fd9ab1c32 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/PurchaseFlowPage.xaml.cs +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/PurchaseFlowPage.xaml.cs @@ -334,7 +334,7 @@ private async Task HandlePurchaseAsync(string sku) catch (Exception ex) { _isProcessing = false; - UpdateResult($"Purchase failed: {ErrorUtils.ExtractErrorMessage(ex)}"); + UpdateResult(ErrorUtils.FormatPurchaseFailure(ex)); RenderProducts(); } } @@ -371,7 +371,7 @@ private async Task ObservePurchaseRequestAsync(Task requ { if (!_isProcessing) return; _isProcessing = false; - UpdateResult($"Purchase failed: {ErrorUtils.ExtractErrorMessage(ex)}"); + UpdateResult(ErrorUtils.FormatPurchaseFailure(ex)); RenderProducts(); }); } @@ -471,7 +471,7 @@ await mutate.FinishTransactionAsync( private void OnPurchaseError(PurchaseError error) { _isProcessing = false; - UpdateResult($"Purchase failed: {error.Message}"); + UpdateResult(ErrorUtils.FormatPurchaseFailure(error)); RenderProducts(); } diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/SubscriptionFlowPage.xaml.cs b/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/SubscriptionFlowPage.xaml.cs index 3cc3965be..a80a4ad9a 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/SubscriptionFlowPage.xaml.cs +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/SubscriptionFlowPage.xaml.cs @@ -531,7 +531,7 @@ private async Task SubmitSubscriptionRequestAsync(ProductSubscription sub, Cance MainThread.BeginInvokeOnMainThread(() => { _isProcessing = false; - UpdateResult($"Subscription failed: {ErrorUtils.ExtractErrorMessage(ex)}"); + UpdateResult(ErrorUtils.FormatPurchaseFailure(ex, "Subscription")); RenderSubscriptions(); }); } @@ -576,7 +576,7 @@ private async Task ObserveSubscriptionRequestAsync( { if (!_isProcessing) return; _isProcessing = false; - UpdateResult($"Subscription failed: {ErrorUtils.ExtractErrorMessage(ex)}"); + UpdateResult(ErrorUtils.FormatPurchaseFailure(ex, "Subscription")); RenderSubscriptions(); }); } @@ -748,7 +748,7 @@ private void OnPurchaseError(PurchaseError error) CancelPurchaseWatchdog(); _isProcessing = false; _isHandlingPurchase = false; - UpdateResult($"Subscription failed: {error.Message}"); + UpdateResult(ErrorUtils.FormatPurchaseFailure(error, "Subscription")); RenderSubscriptions(); } diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/Utils/ErrorUtils.cs b/libraries/maui-iap/example/OpenIap.Maui.Example/Utils/ErrorUtils.cs index 43a0e73c9..f6861cd5e 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/Utils/ErrorUtils.cs +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Utils/ErrorUtils.cs @@ -1,5 +1,7 @@ // Mirrors libraries/expo-iap/example/src/utils/errorUtils.ts. +using OpenIap; + namespace OpenIap.Maui.Example.Utils; public static class ErrorUtils @@ -18,4 +20,16 @@ public static string ExtractErrorMessage(object? error) _ => error.ToString() ?? "Unknown error", }; } + + public static bool IsUserCancelled(object? error) => error switch + { + OpenIapException ex => ex.Error.Code == ErrorCode.UserCancelled, + PurchaseError purchaseError => purchaseError.Code == ErrorCode.UserCancelled, + _ => false, + }; + + public static string FormatPurchaseFailure(object error, string operation = "Purchase") => + IsUserCancelled(error) + ? $"{operation} cancelled by user" + : $"{operation} failed: {ExtractErrorMessage(error)}"; } diff --git a/libraries/maui-iap/src/Directory.Build.props b/libraries/maui-iap/src/Directory.Build.props index faefd256f..0024ccd38 100644 --- a/libraries/maui-iap/src/Directory.Build.props +++ b/libraries/maui-iap/src/Directory.Build.props @@ -1,18 +1,20 @@ + - 8.3.0 + 9.1.0 3.0.8 - 77.0.1 - 1.1.1 + 2.0.0 + 0.2.0 + 1.11.0.1 2.10.1 - 8.3.0.2 + 9.1.0.1 2.14.0 - 1.12.4.1 - 1.8.9.2 - 2.10.0.2 - 1.4.0.2 - 2.3.10.1 - 1.10.2.3 + 1.13.0.1 + 1.8.9.4 + 2.11.0.1 + 1.5.0.1 + 2.4.0.1 + 1.11.0.1 diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj b/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj index 7f09b5201..4d66d1fc5 100644 --- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj +++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj @@ -65,8 +65,12 @@ - + + + + + - + @@ -96,8 +96,12 @@ - + + + + + diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs index 7d4654ed9..e6de0fa21 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs @@ -119,14 +119,44 @@ public async Task IsBillingProgramAvail ?? throw OpenIapErrorMapper.Wrap(ErrorCode.Unknown, "Empty isBillingProgramAvailable result"); } - public async Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program) + public async Task GetBillingChoiceInfoAndroidAsync(GetBillingChoiceInfoParamsAndroid @params) + { + var json = JsonSerializer.Serialize(@params, JsonOptions.Default); + var result = await Invoke(cb => _module.GetBillingChoiceInfoAndroid(json, cb)); + return JsonSerializer.Deserialize(result, JsonOptions.Default) + ?? throw OpenIapErrorMapper.Wrap(ErrorCode.Unknown, "Empty getBillingChoiceInfo result"); + } + + public async Task CreateBillingProgramReportingDetailsAndroidAsync( + BillingProgramAndroid program, + DeveloperBillingTypeAndroid? developerBillingType = null) { var json = JsonSerializer.Serialize(program, JsonOptions.Default); - var result = await Invoke(cb => _module.CreateBillingProgramReportingDetailsAndroid(json, cb)); + var result = developerBillingType is null + ? await Invoke(cb => _module.CreateBillingProgramReportingDetailsAndroid(json, cb)) + : await Invoke(cb => _module.CreateBillingProgramReportingDetailsAndroidWithType(json, developerBillingType.Value.ToJson(), cb)); return JsonSerializer.Deserialize(result, JsonOptions.Default) ?? throw OpenIapErrorMapper.Wrap(ErrorCode.Unknown, "Empty createBillingProgramReportingDetails result"); } + public async Task ShowBillingProgramInformationDialogAndroidAsync(BillingProgramInformationDialogParamsAndroid @params) + { + RefreshCurrentActivity(); + var json = JsonSerializer.Serialize(@params, JsonOptions.Default); + var result = await Invoke(cb => _module.ShowBillingProgramInformationDialogAndroid(json, cb)); + return JsonSerializer.Deserialize(result, JsonOptions.Default) + ?? throw OpenIapErrorMapper.Wrap(ErrorCode.Unknown, "Empty showBillingProgramInformationDialog result"); + } + + public async Task ShowInAppMessagesAndroidAsync(InAppMessageParamsAndroid? @params = null) + { + RefreshCurrentActivity(); + var json = @params is null ? null : JsonSerializer.Serialize(@params, JsonOptions.Default); + var result = await Invoke(cb => _module.ShowInAppMessagesAndroid(json, cb)); + return JsonSerializer.Deserialize(result, JsonOptions.Default) + ?? throw OpenIapErrorMapper.Wrap(ErrorCode.Unknown, "Empty showInAppMessages result"); + } + public Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @params) { RefreshCurrentActivity(); 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 ddd239bb8..c6b7d26e0 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -432,7 +432,10 @@ public Task ShowExternalPurchaseCusto public Task CreateAlternativeBillingTokenAndroidAsync() => Task.FromResult(null); public Task ShowAlternativeBillingDialogAndroidAsync() => Task.FromResult(false); public Task IsBillingProgramAvailableAndroidAsync(BillingProgramAndroid program) => NotSupportedAndroid("isBillingProgramAvailableAndroid"); - public Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program) => NotSupportedAndroid("createBillingProgramReportingDetailsAndroid"); + public Task GetBillingChoiceInfoAndroidAsync(GetBillingChoiceInfoParamsAndroid @params) => NotSupportedAndroid("getBillingChoiceInfoAndroid"); + public Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program, DeveloperBillingTypeAndroid? developerBillingType = null) => NotSupportedAndroid("createBillingProgramReportingDetailsAndroid"); + public Task ShowBillingProgramInformationDialogAndroidAsync(BillingProgramInformationDialogParamsAndroid @params) => NotSupportedAndroid("showBillingProgramInformationDialogAndroid"); + public Task ShowInAppMessagesAndroidAsync(InAppMessageParamsAndroid? @params = null) => NotSupportedAndroid("showInAppMessagesAndroid"); public Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @params) => Task.FromResult(false); // ==================================================================== diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index d35ad4222..9b60900f3 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -78,8 +78,120 @@ public static class AlternativeBillingModeAndroidExtensions public static AlternativeBillingModeAndroid FromJson(string value) => AlternativeBillingModeAndroidJsonConverter.FromRawString(value); } -/// Billing program types for external content links, external offers, and external payments (Android) -/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +/// Play Billing choice image layout (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +[JsonConverter(typeof(BillingChoiceImageLayoutAndroidJsonConverter))] +public enum BillingChoiceImageLayoutAndroid +{ + /// Rectangular image with a 4:1 aspect ratio. + RectangularFourByOne, + /// Rectangular image with a 3:1 aspect ratio. + RectangularThreeByOne, + /// Rectangular image with a 2:2 aspect ratio. + RectangularTwoByTwo +} + +public sealed class BillingChoiceImageLayoutAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["rectangular-four-by-one"] = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + ["RECTANGULAR_FOUR_BY_ONE"] = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + ["rectangular-three-by-one"] = BillingChoiceImageLayoutAndroid.RectangularThreeByOne, + ["RECTANGULAR_THREE_BY_ONE"] = BillingChoiceImageLayoutAndroid.RectangularThreeByOne, + ["rectangular-two-by-two"] = BillingChoiceImageLayoutAndroid.RectangularTwoByTwo, + ["RECTANGULAR_TWO_BY_TWO"] = BillingChoiceImageLayoutAndroid.RectangularTwoByTwo, + }; + + private static readonly Dictionary _toString = new() + { + [BillingChoiceImageLayoutAndroid.RectangularFourByOne] = "rectangular-four-by-one", + [BillingChoiceImageLayoutAndroid.RectangularThreeByOne] = "rectangular-three-by-one", + [BillingChoiceImageLayoutAndroid.RectangularTwoByTwo] = "rectangular-two-by-two", + }; + + public override BillingChoiceImageLayoutAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown BillingChoiceImageLayoutAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, BillingChoiceImageLayoutAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(BillingChoiceImageLayoutAndroid value) => _toString[value]; + internal static BillingChoiceImageLayoutAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown BillingChoiceImageLayoutAndroid value: {value}"); +} + +public static class BillingChoiceImageLayoutAndroidExtensions +{ + public static string ToJson(this BillingChoiceImageLayoutAndroid value) => BillingChoiceImageLayoutAndroidJsonConverter.ToRawString(value); + public static BillingChoiceImageLayoutAndroid FromJson(string value) => BillingChoiceImageLayoutAndroidJsonConverter.FromRawString(value); +} + +/// Choice screen renderer for Billing Choice availability (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +[JsonConverter(typeof(BillingChoiceScreenTypeAndroidJsonConverter))] +public enum BillingChoiceScreenTypeAndroid +{ + /// Unspecified choice screen type. + Unspecified, + /// Choice screen is rendered by the developer app. + DeveloperRendered, + /// Choice screen is rendered by Google Play. + GoogleRendered +} + +public sealed class BillingChoiceScreenTypeAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["unspecified"] = BillingChoiceScreenTypeAndroid.Unspecified, + ["UNSPECIFIED"] = BillingChoiceScreenTypeAndroid.Unspecified, + ["developer-rendered"] = BillingChoiceScreenTypeAndroid.DeveloperRendered, + ["DEVELOPER_RENDERED"] = BillingChoiceScreenTypeAndroid.DeveloperRendered, + ["google-rendered"] = BillingChoiceScreenTypeAndroid.GoogleRendered, + ["GOOGLE_RENDERED"] = BillingChoiceScreenTypeAndroid.GoogleRendered, + }; + + private static readonly Dictionary _toString = new() + { + [BillingChoiceScreenTypeAndroid.Unspecified] = "unspecified", + [BillingChoiceScreenTypeAndroid.DeveloperRendered] = "developer-rendered", + [BillingChoiceScreenTypeAndroid.GoogleRendered] = "google-rendered", + }; + + public override BillingChoiceScreenTypeAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown BillingChoiceScreenTypeAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, BillingChoiceScreenTypeAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(BillingChoiceScreenTypeAndroid value) => _toString[value]; + internal static BillingChoiceScreenTypeAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown BillingChoiceScreenTypeAndroid value: {value}"); +} + +public static class BillingChoiceScreenTypeAndroidExtensions +{ + public static string ToJson(this BillingChoiceScreenTypeAndroid value) => BillingChoiceScreenTypeAndroidJsonConverter.ToRawString(value); + public static BillingChoiceScreenTypeAndroid FromJson(string value) => BillingChoiceScreenTypeAndroidJsonConverter.FromRawString(value); +} + +/// Billing program types for Google Play Billing Programs (Android) +/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, +/// BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (requires Play Billing 9.1.0+). [JsonConverter(typeof(BillingProgramAndroidJsonConverter))] public enum BillingProgramAndroid { @@ -101,7 +213,11 @@ public enum BillingProgramAndroid /// Allows presenting a side-by-side choice between Google Play Billing and developer's external payment option. /// Users can choose to complete the purchase on the developer's website. /// Available in Google Play Billing Library 8.3.0+ - ExternalPayments + ExternalPayments, + /// Billing Choice program. + /// Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + BillingChoice } public sealed class BillingProgramAndroidJsonConverter : JsonConverter @@ -118,6 +234,8 @@ public sealed class BillingProgramAndroidJsonConverter : JsonConverter _toString = new() @@ -127,6 +245,7 @@ public sealed class BillingProgramAndroidJsonConverter : JsonConverter DeveloperBillingLaunchModeAndroidJsonConverter.FromRawString(value); } +/// Developer-provided billing destination type for Billing Program reporting details (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +[JsonConverter(typeof(DeveloperBillingTypeAndroidJsonConverter))] +public enum DeveloperBillingTypeAndroid +{ + /// Unspecified developer billing type. Do not use. + DeveloperBillingTypeUnspecified, + /// Developer-provided billing via native in-app experience. + InApp, + /// Developer-provided billing via external link or embedded web browsing. + ExternalLink +} + +public sealed class DeveloperBillingTypeAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["developer-billing-type-unspecified"] = DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified, + ["DEVELOPER_BILLING_TYPE_UNSPECIFIED"] = DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified, + ["in-app"] = DeveloperBillingTypeAndroid.InApp, + ["IN_APP"] = DeveloperBillingTypeAndroid.InApp, + ["external-link"] = DeveloperBillingTypeAndroid.ExternalLink, + ["EXTERNAL_LINK"] = DeveloperBillingTypeAndroid.ExternalLink, + }; + + private static readonly Dictionary _toString = new() + { + [DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified] = "developer-billing-type-unspecified", + [DeveloperBillingTypeAndroid.InApp] = "in-app", + [DeveloperBillingTypeAndroid.ExternalLink] = "external-link", + }; + + public override DeveloperBillingTypeAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown DeveloperBillingTypeAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, DeveloperBillingTypeAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(DeveloperBillingTypeAndroid value) => _toString[value]; + internal static DeveloperBillingTypeAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown DeveloperBillingTypeAndroid value: {value}"); +} + +public static class DeveloperBillingTypeAndroidExtensions +{ + public static string ToJson(this DeveloperBillingTypeAndroid value) => DeveloperBillingTypeAndroidJsonConverter.ToRawString(value); + public static DeveloperBillingTypeAndroid FromJson(string value) => DeveloperBillingTypeAndroidJsonConverter.FromRawString(value); +} + /// Discount offer type enumeration. /// Categorizes the type of discount or promotional offer. [JsonConverter(typeof(DiscountOfferTypeJsonConverter))] @@ -774,8 +948,9 @@ public enum IapEvent PurchaseError, PromotedProductIOS, UserChoiceBillingAndroid, - /// Fired when user selects developer-provided billing option in external payments flow. - /// Available on Android with Google Play Billing Library 8.3.0+ + /// Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + /// developer billing selections on Android. Billing Choice is available in + /// OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). DeveloperProvidedBillingAndroid, /// Fired when an active subscription enters a billing-issue state that requires user attention. /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and @@ -1035,6 +1210,108 @@ public static class IapStoreExtensions public static IapStore FromJson(string value) => IapStoreJsonConverter.FromRawString(value); } +/// High-level in-app message category (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +[JsonConverter(typeof(InAppMessageCategoryAndroidJsonConverter))] +public enum InAppMessageCategoryAndroid +{ + /// Unknown in-app message category. + UnknownInAppMessageCategoryId, + /// Transactional billing messages, such as payment issues or pending price-change confirmations. + Transactional +} + +public sealed class InAppMessageCategoryAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["unknown-in-app-message-category-id"] = InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId, + ["UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID"] = InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId, + ["transactional"] = InAppMessageCategoryAndroid.Transactional, + ["TRANSACTIONAL"] = InAppMessageCategoryAndroid.Transactional, + }; + + private static readonly Dictionary _toString = new() + { + [InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId] = "unknown-in-app-message-category-id", + [InAppMessageCategoryAndroid.Transactional] = "transactional", + }; + + public override InAppMessageCategoryAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown InAppMessageCategoryAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, InAppMessageCategoryAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(InAppMessageCategoryAndroid value) => _toString[value]; + internal static InAppMessageCategoryAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown InAppMessageCategoryAndroid value: {value}"); +} + +public static class InAppMessageCategoryAndroidExtensions +{ + public static string ToJson(this InAppMessageCategoryAndroid value) => InAppMessageCategoryAndroidJsonConverter.ToRawString(value); + public static InAppMessageCategoryAndroid FromJson(string value) => InAppMessageCategoryAndroidJsonConverter.FromRawString(value); +} + +/// Response code from Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +[JsonConverter(typeof(InAppMessageResponseCodeAndroidJsonConverter))] +public enum InAppMessageResponseCodeAndroid +{ + /// Flow finished and no developer action is needed. + NoActionNeeded, + /// Subscription status changed and the purchase token should be checked. + SubscriptionStatusUpdated +} + +public sealed class InAppMessageResponseCodeAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["no-action-needed"] = InAppMessageResponseCodeAndroid.NoActionNeeded, + ["NO_ACTION_NEEDED"] = InAppMessageResponseCodeAndroid.NoActionNeeded, + ["subscription-status-updated"] = InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated, + ["SUBSCRIPTION_STATUS_UPDATED"] = InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated, + }; + + private static readonly Dictionary _toString = new() + { + [InAppMessageResponseCodeAndroid.NoActionNeeded] = "no-action-needed", + [InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated] = "subscription-status-updated", + }; + + public override InAppMessageResponseCodeAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown InAppMessageResponseCodeAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, InAppMessageResponseCodeAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(InAppMessageResponseCodeAndroid value) => _toString[value]; + internal static InAppMessageResponseCodeAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown InAppMessageResponseCodeAndroid value: {value}"); +} + +public static class InAppMessageResponseCodeAndroidExtensions +{ + public static string ToJson(this InAppMessageResponseCodeAndroid value) => InAppMessageResponseCodeAndroidJsonConverter.ToRawString(value); + public static InAppMessageResponseCodeAndroid FromJson(string value) => InAppMessageResponseCodeAndroidJsonConverter.FromRawString(value); +} + /// Payment mode for subscription offers. /// Determines how the user pays during the offer period. [JsonConverter(typeof(PaymentModeJsonConverter))] @@ -2305,6 +2582,7 @@ public interface PurchaseCommon int Quantity { get; } /// Store where purchase was made IapStore Store { get; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. double TransactionDate { get; } } @@ -2375,6 +2653,7 @@ public sealed record ActiveSubscription /// pending upgrades/downgrades, and auto-renewal preferences. [JsonPropertyName("renewalInfoIOS")] public RenewalInfoIOS? RenewalInfoIOS { get; init; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] public required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] @@ -2479,6 +2758,18 @@ public sealed record AppTransaction public required double SignedDate { get; init; } } +/// Display information for developer-rendered Billing Choice screens (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public sealed record BillingChoiceInfoAndroid +{ + /// URL for the Play Billing choice image matching the requested layout. + [JsonPropertyName("playBillingChoiceImageUrl")] + public required string PlayBillingChoiceImageUrl { get; init; } + /// Play Loyalty information for the user. + [JsonPropertyName("playBillingLoyaltyInfo")] + public string? PlayBillingLoyaltyInfo { get; init; } +} + /// Result of checking billing program availability (Android) /// Available in Google Play Billing Library 8.2.0+ public sealed record BillingProgramAvailabilityResultAndroid @@ -2486,9 +2777,18 @@ public sealed record BillingProgramAvailabilityResultAndroid /// The billing program that was checked [JsonPropertyName("billingProgram")] public required BillingProgramAndroid BillingProgram { get; init; } + /// Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + [JsonPropertyName("choiceScreenType")] + public BillingChoiceScreenTypeAndroid? ChoiceScreenType { get; init; } /// Whether the billing program is available for the user [JsonPropertyName("isAvailable")] public required bool IsAvailable { get; init; } + /// Whether external-link payment is available for Billing Choice. + /// Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + [JsonPropertyName("isExternalLinkAvailable")] + public bool? IsExternalLinkAvailable { get; init; } } /// Reporting details for transactions made outside of Google Play Billing (Android) @@ -2527,10 +2827,34 @@ public sealed record BillingResultAndroid public sealed record DeveloperProvidedBillingDetailsAndroid { /// External transaction token used to report transactions made through developer billing. - /// This token must be used when reporting the external transaction to Google Play. - /// Must be reported within 24 hours of the transaction. + /// Nullable for flows such as external payments where no token is returned. [JsonPropertyName("externalTransactionToken")] - public required string ExternalTransactionToken { get; init; } + public string? ExternalTransactionToken { get; init; } + /// URI to launch for an external-link Billing Choice flow, when provided by + /// Google Play. + [JsonPropertyName("linkUri")] + public string? LinkUri { get; init; } + /// Original external transaction ID when replacing a subscription that was + /// purchased through developer billing. + [JsonPropertyName("originalExternalTransactionId")] + public string? OriginalExternalTransactionId { get; init; } + /// Products selected for the developer billing flow. + [JsonPropertyName("products")] + public required IReadOnlyList Products { get; init; } +} + +/// Product selected for developer-provided billing (Android 9.0+). +public sealed record DeveloperProvidedBillingProductAndroid +{ + /// Product identifier. + [JsonPropertyName("id")] + public required string Id { get; init; } + /// Subscription offer token, when applicable. + [JsonPropertyName("offerToken")] + public string? OfferToken { get; init; } + /// Google Play product type (in-app or subscription). + [JsonPropertyName("type")] + public required ProductType Type { get; init; } } /// Discount amount details for one-time purchase offers (Android) @@ -2764,6 +3088,19 @@ public sealed record FetchProductsResultProducts(IReadOnlyList? Value) public sealed record FetchProductsResultSubscriptions(IReadOnlyList? Value) : FetchProductsResult; +/// Result from showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public sealed record InAppMessageResultAndroid +{ + /// Purchase token returned when a subscription status changed. + [JsonPropertyName("purchaseToken")] + public string? PurchaseToken { get; init; } + /// Response code for the in-app messaging flow. + [JsonPropertyName("responseCode")] + public required InAppMessageResponseCodeAndroid ResponseCode { get; init; } +} + /// Installment plan details for subscription offers (Android) /// Contains information about the installment plan commitment. /// Available in Google Play Billing Library 7.0+ @@ -2874,7 +3211,7 @@ public sealed record ProductAndroid : Product, ProductCommon [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = IapPlatform.Android; + public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; [JsonPropertyName("price")] public double? Price { get; init; } /// Product-level status code indicating fetch result (Android 8.0+) @@ -2895,7 +3232,7 @@ public sealed record ProductAndroid : Product, ProductCommon [JsonPropertyName("title")] public required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = ProductType.InApp; + public ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; } /// One-time purchase offer details (Android). @@ -2968,7 +3305,7 @@ public sealed record ProductIOS : Product, ProductCommon [JsonPropertyName("jsonRepresentationIOS")] public required string JsonRepresentationIOS { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = IapPlatform.IOS; + public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; [JsonPropertyName("price")] public double? Price { get; init; } /// iOS 26.4+ subscription pricing terms, including billing plan metadata for @@ -2987,7 +3324,7 @@ public sealed record ProductIOS : Product, ProductCommon [JsonPropertyName("title")] public required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = ProductType.InApp; + public ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; [JsonPropertyName("typeIOS")] public required ProductTypeIOS TypeIOS { get; init; } } @@ -3019,7 +3356,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = IapPlatform.Android; + public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; [JsonPropertyName("price")] public double? Price { get; init; } /// Product-level status code indicating fetch result (Android 8.0+) @@ -3040,7 +3377,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo [JsonPropertyName("title")] public required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = ProductType.Subs; + public ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; } /// Subscription offer details (Android). @@ -3099,7 +3436,7 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("jsonRepresentationIOS")] public required string JsonRepresentationIOS { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = IapPlatform.IOS; + public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; [JsonPropertyName("price")] public double? Price { get; init; } /// iOS 26.4+ subscription pricing terms, including billing plan metadata for @@ -3124,7 +3461,7 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("title")] public required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = ProductType.Subs; + public ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; [JsonPropertyName("typeIOS")] public required ProductTypeIOS TypeIOS { get; init; } } @@ -3181,6 +3518,7 @@ public sealed record PurchaseAndroid : Purchase, PurchaseCommon /// Store where purchase was made [JsonPropertyName("store")] public required IapStore Store { get; init; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] public required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] @@ -3281,6 +3619,7 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon public string? StorefrontCountryCodeIOS { get; init; } [JsonPropertyName("subscriptionGroupIdIOS")] public string? SubscriptionGroupIdIOS { get; init; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] public required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] @@ -3774,6 +4113,18 @@ public sealed record AndroidSubscriptionOfferInput public required string OfferToken { get; init; } } +/// Parameters for showing a billing program information dialog (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public sealed record BillingProgramInformationDialogParamsAndroid +{ + /// Billing program. Currently only BILLING_CHOICE is supported. + [JsonPropertyName("billingProgram")] + public BillingProgramAndroid BillingProgram { get; init; } = global::OpenIap.BillingProgramAndroid.BillingChoice; + /// External transaction token returned by the Billing Choice reporting-details flow. + [JsonPropertyName("externalTransactionToken")] + public required string ExternalTransactionToken { get; init; } +} + public sealed record DeepLinkOptions { /// Android SKU to open (required on Android) @@ -3784,20 +4135,28 @@ public sealed record DeepLinkOptions public string? PackageNameAndroid { get; init; } } -/// Parameters for developer billing option in purchase flow (Android) -/// Used with BillingFlowParams to enable external payments flow -/// Available in Google Play Billing Library 8.3.0+ +/// Parameters for a developer billing option in a purchase flow (Android). +/// Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice +/// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). +/// Only billingProgram is required; link fields are used when the selected program +/// links outside the app. public sealed record DeveloperBillingOptionParamsAndroid { - /// The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + /// The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. [JsonPropertyName("billingProgram")] public required BillingProgramAndroid BillingProgram { get; init; } - /// The URI where the external payment will be processed + /// The URI where the external payment will be processed. + /// Required only when the selected billing program links outside the app. [JsonPropertyName("linkUri")] - public required string LinkUri { get; init; } - /// The launch mode for the external payment link + public string? LinkUri { get; init; } + /// The launch mode for the external payment link. + /// Required only when the selected billing program links outside the app. [JsonPropertyName("launchMode")] - public required DeveloperBillingLaunchModeAndroid LaunchMode { get; init; } + public DeveloperBillingLaunchModeAndroid? LaunchMode { get; init; } + /// A pre-generated external transaction token for a Billing Choice external-link + /// flow. Omit it when Google Play should provide the token in the callback. + [JsonPropertyName("externalTransactionToken")] + public string? ExternalTransactionToken { get; init; } } public sealed record DiscountOfferInputIOS @@ -3819,6 +4178,31 @@ public sealed record DiscountOfferInputIOS public required double Timestamp { get; init; } } +/// Parameters for fetching Billing Choice display information (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public sealed record GetBillingChoiceInfoParamsAndroid +{ + /// Billing program. Currently only BILLING_CHOICE is supported. + [JsonPropertyName("billingProgram")] + public BillingProgramAndroid BillingProgram { get; init; } = global::OpenIap.BillingProgramAndroid.BillingChoice; + /// Desired Play Billing choice image layout. + [JsonPropertyName("playBillingChoiceImageLayout")] + public BillingChoiceImageLayoutAndroid PlayBillingChoiceImageLayout { get; init; } = global::OpenIap.BillingChoiceImageLayoutAndroid.RectangularFourByOne; + /// BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + [JsonPropertyName("userLocale")] + public string? UserLocale { get; init; } +} + +/// Parameters for showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public sealed record InAppMessageParamsAndroid +{ + /// In-app message categories to show. Defaults to transactional messages. + [JsonPropertyName("categories")] + public IReadOnlyList? Categories { get; init; } = new List { global::OpenIap.InAppMessageCategoryAndroid.Transactional }; +} + /// Connection initialization configuration public sealed record InitConnectionConfig { @@ -3834,16 +4218,28 @@ public sealed record InitConnectionConfig /// - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) /// - EXTERNAL_OFFER: External offers for digital content (8.2.0+) /// - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + /// - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + /// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) [JsonPropertyName("enableBillingProgramAndroid")] public BillingProgramAndroid? EnableBillingProgramAndroid { get; init; } + /// Billing Choice renderer configured in Play Console. Available in OpenIAP + /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + /// can emit the selection event. DEVELOPER_RENDERED omits that listener so the + /// app can render its own choice screen and use the reporting/dialog/link APIs. + /// Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + /// Defaults to GOOGLE_RENDERED. + [JsonPropertyName("billingChoiceScreenTypeAndroid")] + public BillingChoiceScreenTypeAndroid? BillingChoiceScreenTypeAndroid { get; init; } = global::OpenIap.BillingChoiceScreenTypeAndroid.GoogleRendered; } /// Parameters for launching an external link (Android) -/// Used with launchExternalLink to initiate external offer or app install flows +/// Used with launchExternalLink to initiate external offer, app install, or +/// developer-rendered Billing Choice flows /// Available in Google Play Billing Library 8.2.0+ public sealed record LaunchExternalLinkParamsAndroid { - /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + /// The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) [JsonPropertyName("billingProgram")] public required BillingProgramAndroid BillingProgram { get; init; } /// The external link launch mode @@ -3855,6 +4251,11 @@ public sealed record LaunchExternalLinkParamsAndroid /// The URI where the content will be accessed from [JsonPropertyName("linkUri")] public required string LinkUri { get; init; } + /// External transaction token for a developer-rendered Billing Choice external-link + /// flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + [JsonPropertyName("externalTransactionToken")] + public string? ExternalTransactionToken { get; init; } } public sealed record ProductRequest @@ -3862,7 +4263,7 @@ public sealed record ProductRequest [JsonPropertyName("skus")] public required IReadOnlyList Skus { get; init; } [JsonPropertyName("type")] - public ProductQueryType? Type { get; init; } + public ProductQueryType? Type { get; init; } = global::OpenIap.ProductQueryType.InApp; } /// JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025). @@ -3930,9 +4331,9 @@ public sealed record RequestPurchaseAndroidProps /// to apply a discount offer to the purchase. [JsonPropertyName("offerToken")] public string? OfferToken { get; init; } - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). [JsonPropertyName("developerBillingOption")] public DeveloperBillingOptionParamsAndroid? DeveloperBillingOption { get; init; } } @@ -4019,6 +4420,11 @@ public sealed record RequestSubscriptionAndroidProps /// Purchase token for upgrades/downgrades [JsonPropertyName("purchaseToken")] public string? PurchaseToken { get; init; } + /// Original external transaction ID for replacing a subscription that was + /// purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). + [JsonPropertyName("originalExternalTransactionId")] + public string? OriginalExternalTransactionId { get; init; } /// Replacement mode for subscription changes /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) [JsonPropertyName("replacementMode")] @@ -4030,9 +4436,9 @@ public sealed record RequestSubscriptionAndroidProps /// Use this instead of replacementMode for item-level replacement [JsonPropertyName("subscriptionProductReplacementParams")] public SubscriptionProductReplacementParamsAndroid? SubscriptionProductReplacementParams { get; init; } - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). [JsonPropertyName("developerBillingOption")] public DeveloperBillingOptionParamsAndroid? DeveloperBillingOption { get; init; } } @@ -4270,7 +4676,6 @@ public interface MutationResolver Task BeginRefundRequestIOSAsync(string sku); /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -4287,7 +4692,6 @@ public interface MutationResolver /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. /// Token must be reported to Google Play backend within 24 hours. - /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -4295,11 +4699,14 @@ public interface MutationResolver /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. - /// /// Returns external transaction token needed for reporting external transactions. + /// developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + /// native Android defaults it to IN_APP. + /// The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android - Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program); + Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program, DeveloperBillingTypeAndroid? developerBillingType = null); /// Open the platform's subscription management UI. /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions @@ -4319,16 +4726,17 @@ public interface MutationResolver /// Check whether a billing program (e.g., External Payments) is available for the current user. /// Replaces the deprecated isExternalOfferAvailableAsync API. - /// /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android Task IsBillingProgramAvailableAndroidAsync(BillingProgramAndroid program); - /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + /// including developer-rendered Billing Choice external-link flows. + /// Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. - /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -4366,18 +4774,32 @@ public interface MutationResolver /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. - /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android Task ShowAlternativeBillingDialogAndroidAsync(); + /// Show Google's mandatory information dialog before a developer-rendered, + /// in-app Billing Choice screen. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + Task ShowBillingProgramInformationDialogAndroidAsync(BillingProgramInformationDialogParamsAndroid @params); + /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios Task ShowExternalPurchaseCustomLinkNoticeIOSAsync(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); + /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + /// (upstream API available since Play Billing 4.1.0). + /// Returns a response code and, when the subscription status changes, the related purchase token. + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + Task ShowInAppMessagesAndroidAsync(InAppMessageParamsAndroid? @params = null); + /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios Task> ShowManageSubscriptionsIOSAsync(); @@ -4440,6 +4862,12 @@ public interface QueryResolver /// See: https://openiap.dev/docs/apis/get-available-purchases Task> GetAvailablePurchasesAsync(PurchaseOptions? options = null); + /// Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client is not ready. + /// See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + Task GetBillingChoiceInfoAndroidAsync(GetBillingChoiceInfoParamsAndroid @params); + /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) @@ -4504,11 +4932,11 @@ public interface QueryResolver /// GraphQL root subscription operations. public interface SubscriptionResolver { - /// Fires when a user selects developer billing in the External Payments flow (Android only) - /// Triggered when the user chooses to pay via the developer's external payment option - /// instead of Google Play Billing in the side-by-side choice dialog. - /// Contains the externalTransactionToken needed to report the transaction. - /// Available in Google Play Billing Library 8.3.0+ + /// Fires when a user selects developer billing in an External Payments or + /// Billing Choice flow (Android only). The payload can contain an external + /// transaction token, link URI, original transaction ID, and selected products. + /// Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). Task DeveloperProvidedBillingAndroidAsync(); /// Fires when the App Store surfaces a promoted product (iOS only) diff --git a/libraries/react-native-iap/CLAUDE.md b/libraries/react-native-iap/CLAUDE.md index 2e70b2f57..426e60506 100644 --- a/libraries/react-native-iap/CLAUDE.md +++ b/libraries/react-native-iap/CLAUDE.md @@ -134,7 +134,7 @@ Access these from the Run and Debug panel (⌘⇧D) in VSCode. 3. **Native Implementation** - iOS: `ios/HybridIAP.swift` - StoreKit 2 implementation - Android: `android/.../Iap.kt` - Play Billing implementation with auto-reconnection - - **Connection Management**: Uses Google Play Billing v8.0.0+ automatic service reconnection + - **Connection Management**: Uses Google Play Billing v9.1.0 automatic service reconnection ## Coding Standards diff --git a/libraries/react-native-iap/README.md b/libraries/react-native-iap/README.md index cfb4d6f40..578a1d786 100644 --- a/libraries/react-native-iap/README.md +++ b/libraries/react-native-iap/README.md @@ -79,7 +79,7 @@ React Native IAP is built with a modern architecture that emphasizes: | Platform | Support | Notes | | ----------------- | ------- | -------------------------------- | | iOS | ✅ | StoreKit 2 (requires iOS 15+) | -| Android | ✅ | Google Play Billing v8.0.0+ | +| Android | ✅ | Google Play Billing v9.1.0 | | Expo Go | ❌ | Use `expo-iap` for Expo projects | | Expo Dev Client | ❌ | Use `expo-iap` for Expo projects | | Bare React Native | ✅ | Full support | @@ -170,7 +170,7 @@ React Native IAP conforms to the **[OpenIAP specification](https://openiap.dev)* - **Shared specification** — Common types, error codes, and purchase flows across all platforms - **Generated type-safe bindings** — Swift, Kotlin, TypeScript, Dart, C#, and GDScript from a single GraphQL schema -- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 8.x) +- **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 9.1.0) - **Verification profiles** — Standardized receipt validation and purchase verification patterns Other libraries built on OpenIAP: [expo-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap) · [flutter_inapp_purchase](https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase) · [kmp-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap) · [maui-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/maui-iap) · [godot-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/godot-iap) 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 67b117aae..95e9ac6f6 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 @@ -18,7 +18,7 @@ import dev.hyo.openiap.ProductRequest import dev.hyo.openiap.ProductSubscriptionAndroid import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails import dev.hyo.openiap.ProductCommon -import dev.hyo.openiap.ProductType +import dev.hyo.openiap.ProductType as OpenIapProductType import dev.hyo.openiap.Purchase as OpenIapPurchase import dev.hyo.openiap.PurchaseAndroid import dev.hyo.openiap.RequestPurchaseAndroidProps @@ -28,6 +28,7 @@ import dev.hyo.openiap.RequestPurchaseResultPurchase import dev.hyo.openiap.RequestPurchaseResultPurchases import dev.hyo.openiap.RequestSubscriptionAndroidProps import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms +import dev.hyo.openiap.SubResponseCodeAndroid as OpenIapSubResponseCodeAndroid import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid as OpenIapSubscriptionProductReplacementParams import dev.hyo.openiap.SubscriptionReplacementModeAndroid as OpenIapSubscriptionReplacementMode import dev.hyo.openiap.VerifyPurchaseGoogleOptions @@ -37,7 +38,17 @@ import dev.hyo.openiap.InitConnectionConfig as OpenIapInitConnectionConfig import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener +import dev.hyo.openiap.BillingChoiceImageLayoutAndroid as OpenIapBillingChoiceImageLayout +import dev.hyo.openiap.BillingChoiceScreenTypeAndroid as OpenIapBillingChoiceScreenType +import dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid as OpenIapBillingProgramInformationDialogParams import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgramAndroid +import dev.hyo.openiap.DeveloperBillingTypeAndroid as OpenIapDeveloperBillingType +import dev.hyo.openiap.DeveloperBillingLaunchModeAndroid as OpenIapDeveloperBillingLaunchMode +import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid as OpenIapDeveloperBillingOptionParams +import dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid as OpenIapGetBillingChoiceInfoParams +import dev.hyo.openiap.InAppMessageCategoryAndroid as OpenIapInAppMessageCategory +import dev.hyo.openiap.InAppMessageParamsAndroid as OpenIapInAppMessageParams +import dev.hyo.openiap.InAppMessageResponseCodeAndroid as OpenIapInAppMessageResponseCode import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunchMode import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType @@ -55,7 +66,7 @@ import org.json.JSONObject * Custom exception for OpenIAP errors that only includes the error JSON without stack traces. * This ensures clean error messages are passed to JavaScript without Java/Kotlin stack traces. */ -class OpenIapException(private val errorJson: String) : Exception() { +class OpenIapException(private val errorJson: String, cause: Throwable? = null) : Exception(cause) { override val message: String get() = errorJson @@ -201,15 +212,27 @@ class HybridRnIap : HybridRnIapSpec() { sendUserChoiceBilling(nitroDetails) }.onFailure { RnIapLog.failure("userChoiceBillingListener", it) } }) - // Developer Provided Billing listener (External Payments - 8.3.0+) + // Developer Provided Billing listener (External Payments 8.3.0+, Billing Choice 9.1.0+) openIap.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details -> runCatching { RnIapLog.result( "developerProvidedBillingListener", - mapOf("token" to details.externalTransactionToken) + mapOf("productCount" to details.products.size) ) val nitroDetails = DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken = details.externalTransactionToken + externalTransactionToken = details.externalTransactionToken.wrapVariant(), + linkUri = details.linkUri.wrapVariant(), + originalExternalTransactionId = details.originalExternalTransactionId.wrapVariant(), + products = details.products.map { product -> + DeveloperProvidedBillingProductAndroid( + id = product.id, + offerToken = product.offerToken.wrapVariant(), + type = when (product.type) { + OpenIapProductType.InApp -> ProductType.IN_APP + OpenIapProductType.Subs -> ProductType.SUBS + } + ) + }.toTypedArray() ) sendDeveloperProvidedBilling(nitroDetails) }.onFailure { RnIapLog.failure("developerProvidedBillingListener", it) } @@ -255,6 +278,9 @@ class HybridRnIap : HybridRnIapSpec() { }, enableBillingProgramAndroid = configValue.enableBillingProgramAndroid?.let { program -> mapBillingProgram(program) + }, + billingChoiceScreenTypeAndroid = configValue.billingChoiceScreenTypeAndroid?.let { type -> + mapBillingChoiceScreenTypeToOpenIap(type) } ) } @@ -478,6 +504,11 @@ class HybridRnIap : HybridRnIapSpec() { } ?: emptyList() val normalizedOffers = subscriptionOffers.takeIf { it.isNotEmpty() } + val developerBillingOption = + (androidRequest.developerBillingOption as? + Variant_NullType_DeveloperBillingOptionParamsAndroid.Second) + ?.value + ?.let(::mapDeveloperBillingOption) val requestProps = when (queryType) { ProductQueryType.Subs -> { @@ -495,6 +526,8 @@ class HybridRnIap : HybridRnIapSpec() { isOfferPersonalized = androidRequest.isOfferPersonalized.unwrapBool(), obfuscatedAccountId = androidRequest.obfuscatedAccountId.unwrapString(), obfuscatedProfileId = androidRequest.obfuscatedProfileId.unwrapString(), + developerBillingOption = developerBillingOption, + originalExternalTransactionId = androidRequest.originalExternalTransactionId.unwrapString(), purchaseToken = androidRequest.purchaseToken.unwrapString(), replacementMode = replacementMode, skus = androidRequest.skus.toList(), @@ -503,7 +536,7 @@ class HybridRnIap : HybridRnIapSpec() { ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Subscription( - RequestSubscriptionPropsByPlatforms(android = androidProps) + RequestSubscriptionPropsByPlatforms(google = androidProps) ), type = ProductQueryType.Subs ) @@ -513,12 +546,13 @@ class HybridRnIap : HybridRnIapSpec() { isOfferPersonalized = androidRequest.isOfferPersonalized.unwrapBool(), obfuscatedAccountId = androidRequest.obfuscatedAccountId.unwrapString(), obfuscatedProfileId = androidRequest.obfuscatedProfileId.unwrapString(), + developerBillingOption = developerBillingOption, offerToken = androidRequest.offerToken.unwrapString(), skus = androidRequest.skus.toList() ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Purchase( - RequestPurchasePropsByPlatforms(android = androidProps) + RequestPurchasePropsByPlatforms(google = androidProps) ), type = ProductQueryType.InApp ) @@ -966,7 +1000,7 @@ class HybridRnIap : HybridRnIapSpec() { var subscriptionPeriodAndroid: String? = null var freeTrialPeriodAndroid: String? = null - if (product.type == ProductType.InApp) { + if (product.type == OpenIapProductType.InApp) { oneTimeOffers?.firstOrNull()?.let { otp -> originalPriceAndroid = otp.formattedPrice originalPriceAmountMicrosAndroid = otp.priceAmountMicros.toDoubleOrNull() @@ -1489,7 +1523,7 @@ class HybridRnIap : HybridRnIapSpec() { } catch (err: Throwable) { RnIapLog.failure("checkAlternativeBillingAvailabilityAndroid", err) val errorType = parseOpenIapError(err) - throw OpenIapException(toErrorJson(errorType, debugMessage = err.message)) + throw OpenIapException(toErrorJson(errorType, debugMessage = err.message), err) } } } @@ -1511,7 +1545,7 @@ class HybridRnIap : HybridRnIapSpec() { } catch (err: Throwable) { RnIapLog.failure("showAlternativeBillingDialogAndroid", err) val errorType = parseOpenIapError(err) - throw OpenIapException(toErrorJson(errorType, debugMessage = err.message)) + throw OpenIapException(toErrorJson(errorType, debugMessage = err.message), err) } } } @@ -1532,7 +1566,7 @@ class HybridRnIap : HybridRnIapSpec() { } catch (err: Throwable) { RnIapLog.failure("createAlternativeBillingTokenAndroid", err) val errorType = parseOpenIapError(err) - throw OpenIapException(toErrorJson(errorType, debugMessage = err.message)) + throw OpenIapException(toErrorJson(errorType, debugMessage = err.message), err) } } } @@ -1652,25 +1686,66 @@ class HybridRnIap : HybridRnIapSpec() { val result = openIapStore.isBillingProgramAvailable(openIapProgram) val nitroResult = NitroBillingProgramAvailabilityResultAndroid( billingProgram = program, - isAvailable = result.isAvailable + choiceScreenType = result.choiceScreenType?.let { mapBillingChoiceScreenType(it) }, + isAvailable = result.isAvailable, + isExternalLinkAvailable = result.isExternalLinkAvailable.wrapVariant() ) RnIapLog.result("isBillingProgramAvailableAndroid", mapOf("isAvailable" to result.isAvailable)) nitroResult } catch (err: Throwable) { RnIapLog.failure("isBillingProgramAvailableAndroid", err) val errorType = parseOpenIapError(err) - throw OpenIapException(toErrorJson(errorType, debugMessage = err.message)) + throw OpenIapException(toErrorJson(errorType, debugMessage = err.message), err) + } + } + } + + override fun getBillingChoiceInfoAndroid(params: NitroGetBillingChoiceInfoParamsAndroid): Promise { + return Promise.async { + RnIapLog.payload("getBillingChoiceInfoAndroid", mapOf( + "billingProgram" to params.billingProgram.name, + "playBillingChoiceImageLayout" to params.playBillingChoiceImageLayout.name, + "userLocale" to params.userLocale.unwrapString() + )) + try { + ensureConnection() + val result = openIapStore.getBillingChoiceInfo( + OpenIapGetBillingChoiceInfoParams( + billingProgram = mapBillingProgram(params.billingProgram), + playBillingChoiceImageLayout = mapBillingChoiceImageLayout(params.playBillingChoiceImageLayout), + userLocale = params.userLocale.unwrapString() + ) + ) + val nitroResult = NitroBillingChoiceInfoAndroid( + playBillingChoiceImageUrl = result.playBillingChoiceImageUrl, + playBillingLoyaltyInfo = result.playBillingLoyaltyInfo.wrapVariant() + ) + RnIapLog.result("getBillingChoiceInfoAndroid", mapOf("hasImageUrl" to result.playBillingChoiceImageUrl.isNotBlank())) + nitroResult + } catch (err: Throwable) { + RnIapLog.failure("getBillingChoiceInfoAndroid", err) + val errorType = parseOpenIapError(err) + throw OpenIapException(toErrorJson(errorType, debugMessage = err.message), err) } } } - override fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): Promise { + override fun createBillingProgramReportingDetailsAndroid( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? + ): Promise { return Promise.async { - RnIapLog.payload("createBillingProgramReportingDetailsAndroid", mapOf("program" to program.name)) + RnIapLog.payload( + "createBillingProgramReportingDetailsAndroid", + mapOf("program" to program.name, "developerBillingType" to developerBillingType?.name) + ) try { ensureConnection() val openIapProgram = mapBillingProgram(program) - val result = openIapStore.createBillingProgramReportingDetails(openIapProgram) + val result = openIapStore.createBillingProgramReportingDetails( + openIapProgram, + mapDeveloperBillingType(developerBillingType) + ) val nitroResult = NitroBillingProgramReportingDetailsAndroid( billingProgram = program, externalTransactionToken = result.externalTransactionToken @@ -1680,7 +1755,75 @@ class HybridRnIap : HybridRnIapSpec() { } catch (err: Throwable) { RnIapLog.failure("createBillingProgramReportingDetailsAndroid", err) val errorType = parseOpenIapError(err) - throw OpenIapException(toErrorJson(errorType, debugMessage = err.message)) + throw OpenIapException(toErrorJson(errorType, debugMessage = err.message), err) + } + } + } + + override fun showBillingProgramInformationDialogAndroid( + params: NitroBillingProgramInformationDialogParamsAndroid + ): Promise { + return Promise.async { + RnIapLog.payload("showBillingProgramInformationDialogAndroid", mapOf("program" to params.billingProgram.name)) + try { + ensureConnection() + val activity = withContext(Dispatchers.Main) { + runCatching { context.currentActivity }.getOrNull() + } ?: throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Activity not available")) + val result = withContext(Dispatchers.Main) { + openIapStore.showBillingProgramInformationDialog( + activity, + OpenIapBillingProgramInformationDialogParams( + billingProgram = mapBillingProgram(params.billingProgram), + externalTransactionToken = params.externalTransactionToken + ) + ) + } + RnIapLog.result("showBillingProgramInformationDialogAndroid", mapOf("responseCode" to result.responseCode)) + NitroBillingResultAndroid( + responseCode = result.responseCode.toDouble(), + debugMessage = result.debugMessage.wrapVariant(), + subResponseCode = mapSubResponseCode(result.subResponseCode) + ) + } catch (err: Throwable) { + RnIapLog.failure("showBillingProgramInformationDialogAndroid", err) + val errorType = parseOpenIapError(err) + throw OpenIapException(toErrorJson(errorType, debugMessage = err.message), err) + } + } + } + + override fun showInAppMessagesAndroid( + params: Variant_NullType_NitroInAppMessageParamsAndroid? + ): Promise { + return Promise.async { + val messageParams = params?.asSecondOrNull() + val categories = messageParams?.categories?.asSecondOrNull() + RnIapLog.payload("showInAppMessagesAndroid", mapOf("categories" to categories?.map { it.name })) + try { + ensureConnection() + val activity = withContext(Dispatchers.Main) { + runCatching { context.currentActivity }.getOrNull() + } ?: throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Activity not available")) + val result = withContext(Dispatchers.Main) { + openIapStore.showInAppMessages( + activity, + messageParams?.let { + OpenIapInAppMessageParams( + categories = categories?.map { category -> mapInAppMessageCategory(category) } + ) + } + ) + } + RnIapLog.result("showInAppMessagesAndroid", mapOf("responseCode" to result.responseCode.name)) + NitroInAppMessageResultAndroid( + responseCode = mapInAppMessageResponseCode(result.responseCode), + purchaseToken = result.purchaseToken.wrapVariant() + ) + } catch (err: Throwable) { + RnIapLog.failure("showInAppMessagesAndroid", err) + val errorType = parseOpenIapError(err) + throw OpenIapException(toErrorJson(errorType, debugMessage = err.message), err) } } } @@ -1702,6 +1845,7 @@ class HybridRnIap : HybridRnIapSpec() { val openIapParams = OpenIapLaunchExternalLinkParams( billingProgram = mapBillingProgram(params.billingProgram), + externalTransactionToken = params.externalTransactionToken.unwrapString(), launchMode = mapExternalLinkLaunchMode(params.launchMode), linkType = mapExternalLinkType(params.linkType), linkUri = params.linkUri @@ -1728,6 +1872,86 @@ class HybridRnIap : HybridRnIapSpec() { BillingProgramAndroid.EXTERNAL_OFFER -> OpenIapBillingProgramAndroid.ExternalOffer BillingProgramAndroid.EXTERNAL_PAYMENTS -> OpenIapBillingProgramAndroid.ExternalPayments BillingProgramAndroid.USER_CHOICE_BILLING -> OpenIapBillingProgramAndroid.UserChoiceBilling + BillingProgramAndroid.BILLING_CHOICE -> OpenIapBillingProgramAndroid.BillingChoice + } + } + + private fun mapBillingChoiceImageLayout(layout: BillingChoiceImageLayoutAndroid): OpenIapBillingChoiceImageLayout { + return when (layout) { + BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE -> OpenIapBillingChoiceImageLayout.RectangularFourByOne + BillingChoiceImageLayoutAndroid.RECTANGULAR_THREE_BY_ONE -> OpenIapBillingChoiceImageLayout.RectangularThreeByOne + BillingChoiceImageLayoutAndroid.RECTANGULAR_TWO_BY_TWO -> OpenIapBillingChoiceImageLayout.RectangularTwoByTwo + } + } + + private fun mapBillingChoiceScreenType(type: OpenIapBillingChoiceScreenType): BillingChoiceScreenTypeAndroid { + return when (type) { + OpenIapBillingChoiceScreenType.Unspecified -> BillingChoiceScreenTypeAndroid.UNSPECIFIED + OpenIapBillingChoiceScreenType.DeveloperRendered -> BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED + OpenIapBillingChoiceScreenType.GoogleRendered -> BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED + } + } + + private fun mapBillingChoiceScreenTypeToOpenIap( + type: BillingChoiceScreenTypeAndroid + ): OpenIapBillingChoiceScreenType { + return when (type) { + BillingChoiceScreenTypeAndroid.UNSPECIFIED -> OpenIapBillingChoiceScreenType.Unspecified + BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED -> OpenIapBillingChoiceScreenType.DeveloperRendered + BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED -> OpenIapBillingChoiceScreenType.GoogleRendered + } + } + + private fun mapDeveloperBillingType(type: DeveloperBillingTypeAndroid?): OpenIapDeveloperBillingType? { + return when (type) { + null, DeveloperBillingTypeAndroid.DEVELOPER_BILLING_TYPE_UNSPECIFIED -> null + DeveloperBillingTypeAndroid.IN_APP -> OpenIapDeveloperBillingType.InApp + DeveloperBillingTypeAndroid.EXTERNAL_LINK -> OpenIapDeveloperBillingType.ExternalLink + } + } + + private fun mapSubResponseCode( + code: OpenIapSubResponseCodeAndroid? + ): SubResponseCodeAndroid? = when (code) { + null -> null + OpenIapSubResponseCodeAndroid.NoApplicableSubResponseCode -> + SubResponseCodeAndroid.NO_APPLICABLE_SUB_RESPONSE_CODE + OpenIapSubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds -> + SubResponseCodeAndroid.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS + OpenIapSubResponseCodeAndroid.UserIneligible -> + SubResponseCodeAndroid.USER_INELIGIBLE + } + + private fun mapDeveloperBillingOption( + option: DeveloperBillingOptionParamsAndroid + ): OpenIapDeveloperBillingOptionParams { + val launchMode = when (option.launchMode) { + null -> null + DeveloperBillingLaunchModeAndroid.UNSPECIFIED -> OpenIapDeveloperBillingLaunchMode.Unspecified + DeveloperBillingLaunchModeAndroid.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP -> + OpenIapDeveloperBillingLaunchMode.LaunchInExternalBrowserOrApp + DeveloperBillingLaunchModeAndroid.CALLER_WILL_LAUNCH_LINK -> + OpenIapDeveloperBillingLaunchMode.CallerWillLaunchLink + } + return OpenIapDeveloperBillingOptionParams( + billingProgram = mapBillingProgram(option.billingProgram), + externalTransactionToken = option.externalTransactionToken.unwrapString(), + launchMode = launchMode, + linkUri = option.linkUri.unwrapString() + ) + } + + private fun mapInAppMessageCategory(category: InAppMessageCategoryAndroid): OpenIapInAppMessageCategory { + return when (category) { + InAppMessageCategoryAndroid.UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID -> OpenIapInAppMessageCategory.UnknownInAppMessageCategoryId + InAppMessageCategoryAndroid.TRANSACTIONAL -> OpenIapInAppMessageCategory.Transactional + } + } + + private fun mapInAppMessageResponseCode(code: OpenIapInAppMessageResponseCode): InAppMessageResponseCodeAndroid { + return when (code) { + OpenIapInAppMessageResponseCode.NoActionNeeded -> InAppMessageResponseCodeAndroid.NO_ACTION_NEEDED + OpenIapInAppMessageResponseCode.SubscriptionStatusUpdated -> InAppMessageResponseCodeAndroid.SUBSCRIPTION_STATUS_UPDATED } } 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 190ab095c..e609f8dcd 100644 --- a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx @@ -4,6 +4,15 @@ import SubscriptionFlow from '../../screens/SubscriptionFlow'; import * as RNIap from 'react-native-iap'; import {SUBSCRIPTION_PRODUCT_IDS} from '../../src/utils/constants'; +jest.mock( + '@env', + () => ({ + IAPKIT_API_KEY: 'test-api-key', + IAPKIT_BASE_URL: '', + }), + {virtual: true}, +); + const requestPurchaseMock = RNIap.requestPurchase as jest.Mock; const deepLinkToSubscriptionsMock = RNIap.deepLinkToSubscriptions as jest.Mock; @@ -33,7 +42,7 @@ describe('SubscriptionFlow Screen', () => { const getActiveSubscriptions = jest.fn(() => Promise.resolve([])); const finishTransaction = jest.fn(() => Promise.resolve()); const verifyPurchase = jest.fn(() => Promise.resolve({})); - const verifyPurchaseWithProvider = jest.fn(() => + const verifyPurchaseWithProvider = jest.fn((_request: unknown) => Promise.resolve({ iapkit: { isValid: true, @@ -190,6 +199,35 @@ describe('SubscriptionFlow Screen', () => { ); }); + it('re-verifies the Android IAPKit snapshot after finishing', async () => { + Platform.OS = 'android'; + const {finishTransaction, verifyPurchaseWithProvider} = mockIapState(); + + render(); + + await act(async () => { + await onPurchaseSuccess?.({ + id: 'transaction-android-1', + platform: 'android', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'android-token', + transactionDate: Date.now(), + }); + }); + + expect(finishTransaction).toHaveBeenCalledTimes(1); + expect(verifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect(verifyPurchaseWithProvider.mock.calls[1]?.[0]).toEqual( + verifyPurchaseWithProvider.mock.calls[0]?.[0], + ); + expect(verifyPurchaseWithProvider.mock.invocationCallOrder[0]).toBeLessThan( + finishTransaction.mock.invocationCallOrder[0]!, + ); + expect(finishTransaction.mock.invocationCallOrder[0]).toBeLessThan( + verifyPurchaseWithProvider.mock.invocationCallOrder[1]!, + ); + }); + it('shows error message on purchase error callback', async () => { const {getByText} = render(); diff --git a/libraries/react-native-iap/example/android/app/src/main/AndroidManifest.xml b/libraries/react-native-iap/example/android/app/src/main/AndroidManifest.xml index 491f51db2..28994e41b 100644 --- a/libraries/react-native-iap/example/android/app/src/main/AndroidManifest.xml +++ b/libraries/react-native-iap/example/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ { if (Platform.OS !== 'android' || isVega) return; const subscription = developerProvidedBillingListenerAndroid( (details: DeveloperProvidedBillingDetailsAndroid) => { + const externalTransactionToken = + details.externalTransactionToken ?? null; console.log( '[Android] User selected developer billing (External Payments)', ); console.log( '[Android] External transaction token available:', - Boolean(details.externalTransactionToken), + Boolean(externalTransactionToken), ); - setExternalPaymentsToken(details.externalTransactionToken); + setExternalPaymentsToken(externalTransactionToken); setIsProcessing(false); setPurchaseResult( - `✅ User selected Developer Billing (External Payments)\n\nToken: ${details.externalTransactionToken}\n\n⚠️ Important:\n1. Process payment through your external system\n2. Report token to Google Play within 24 hours`, + externalTransactionToken + ? `✅ User selected Developer Billing (External Payments)\n\nToken received.\n\n⚠️ Important:\n1. Process payment through your external system\n2. Report the token to Google Play within 24 hours` + : `✅ User selected Developer Billing (External Payments)\n\nNo external transaction token was returned for this flow.`, ); Alert.alert( diff --git a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx index 64c14d731..e67ecccf6 100644 --- a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx +++ b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx @@ -412,8 +412,7 @@ function SubscriptionFlow({ // For Android, get purchase token from activeSubscriptions const extendedSub = currentSub as - | ExtendedActiveSubscription - | undefined; + ExtendedActiveSubscription | undefined; const purchaseToken = extendedSub?.purchaseToken || extendedSub?.purchaseTokenAndroid; @@ -1107,7 +1106,8 @@ function SubscriptionFlow({ {new Date( - sub.renewalInfoIOS.gracePeriodExpirationDate, + sub.renewalInfoIOS + .gracePeriodExpirationDate, ).toLocaleDateString()} @@ -1770,6 +1770,7 @@ function SubscriptionFlowContainer() { // - iOS: App Store Server API + App Store Server Notifications V2 // - Android: Google Play Developer API + RTDN const currentVerificationMethod = verificationMethodRef.current; + let iapkitVerifyRequest: VerifyPurchaseWithProviderProps | null = null; console.log('[SubscriptionFlow] About to verify purchase:', { verificationMethod: currentVerificationMethod, productId, @@ -1840,6 +1841,7 @@ function SubscriptionFlowContainer() { provider: 'iapkit', iapkit: iapkitPayload, }; + iapkitVerifyRequest = verifyRequest; const iapkitLogPayload = { ...iapkitPayload, apiKey: '***hidden***', @@ -1932,6 +1934,21 @@ function SubscriptionFlowContainer() { purchase, isConsumable, }); + if (Platform.OS === 'android' && iapkitVerifyRequest) { + try { + const refreshedResult = + await verifyPurchaseWithProvider(iapkitVerifyRequest); + console.log( + '[SubscriptionFlow] IAPKit state after delayed finishTransaction:', + refreshedResult, + ); + } catch (error) { + console.log( + '[SubscriptionFlow] IAPKit post-finish verification failed:', + getErrorMessage(error), + ); + } + } if (mountedRef.current) { setPurchaseResult( `Subscription activated and finished successfully.\n` + @@ -2016,6 +2033,21 @@ function SubscriptionFlowContainer() { } if (didFinishTransaction) { + if (Platform.OS === 'android' && iapkitVerifyRequest) { + try { + const refreshedResult = + await verifyPurchaseWithProvider(iapkitVerifyRequest); + console.log( + '[SubscriptionFlow] IAPKit state after finishTransaction:', + refreshedResult, + ); + } catch (error) { + console.log( + '[SubscriptionFlow] IAPKit post-finish verification failed:', + getErrorMessage(error), + ); + } + } showNativeAlert('Success', 'Purchase completed successfully!'); } }, diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 489da92cc..ba29894d3 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -1385,12 +1385,30 @@ class HybridRnIap: HybridRnIapSpec { } } - func createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid) throws -> Promise { + func getBillingChoiceInfoAndroid(params: NitroGetBillingChoiceInfoParamsAndroid) throws -> Promise { + return Promise.async { + throw OpenIapException.make(code: .featureNotSupported, message: "Billing Choice API is Android-only") + } + } + + func createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid?) throws -> Promise { return Promise.async { throw OpenIapException.make(code: .featureNotSupported, message: "Billing Programs API is Android-only") } } + func showBillingProgramInformationDialogAndroid(params: NitroBillingProgramInformationDialogParamsAndroid) throws -> Promise { + return Promise.async { + throw OpenIapException.make(code: .featureNotSupported, message: "Billing Choice API is Android-only") + } + } + + func showInAppMessagesAndroid(params: Variant_NullType_NitroInAppMessageParamsAndroid?) throws -> Promise { + return Promise.async { + throw OpenIapException.make(code: .featureNotSupported, message: "In-app messages are Android-only") + } + } + func launchExternalLinkAndroid(params: NitroLaunchExternalLinkParamsAndroid) throws -> Promise { return Promise.async { throw OpenIapException.make(code: .featureNotSupported, message: "Billing Programs API is Android-only") diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts index e7f6dad45..abd299bdb 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts @@ -34,6 +34,7 @@ jest.mock('react-native', () => ({ // Import after mocks import * as IAP from '../../index'; import {useIAP} from '../../hooks/useIAP'; +import type {DeveloperProvidedBillingDetailsAndroid} from '../../types'; describe('hooks/useIAP Android', () => { afterEach(() => { @@ -81,6 +82,28 @@ describe('hooks/useIAP Android', () => { }); }); + it('passes the Billing Choice renderer to initConnection on Android', async () => { + let api: any; + const Harness = () => { + api = useIAP({ + enableBillingProgramAndroid: 'billing-choice', + billingChoiceScreenTypeAndroid: 'developer-rendered', + }); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + expect(api.connected).toBe(true); + expect(IAP.initConnection).toHaveBeenCalledWith({ + enableBillingProgramAndroid: 'billing-choice', + billingChoiceScreenTypeAndroid: 'developer-rendered', + }); + }); + it('passes alternativeBillingModeAndroid config to initConnection on Android (deprecated)', async () => { let api: any; const Harness = () => { @@ -151,6 +174,65 @@ describe('hooks/useIAP Android', () => { expect(mockUserChoiceBillingListener).toHaveBeenCalled(); }); + it('forwards developer-provided Billing Choice events from the hook', async () => { + let listener: + | Parameters[0] + | undefined; + jest + .spyOn(IAP, 'developerProvidedBillingListenerAndroid') + .mockImplementation((callback) => { + listener = callback; + return {remove: jest.fn()}; + }); + + const onDeveloperProvidedBillingAndroid = jest.fn(); + const Harness = () => { + useIAP({onDeveloperProvidedBillingAndroid}); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + const details: DeveloperProvidedBillingDetailsAndroid = { + externalTransactionToken: 'external-token', + products: [{id: 'premium', type: 'in-app'}], + }; + listener?.(details); + + expect(onDeveloperProvidedBillingAndroid).toHaveBeenCalledWith(details); + }); + + it('exposes all Billing Choice APIs through the hook', async () => { + let api: any; + const Harness = () => { + api = useIAP(); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + expect(api.getBillingChoiceInfoAndroid).toBe( + IAP.getBillingChoiceInfoAndroid, + ); + expect(api.isBillingProgramAvailableAndroid).toBe( + IAP.isBillingProgramAvailableAndroid, + ); + expect(api.createBillingProgramReportingDetailsAndroid).toBe( + IAP.createBillingProgramReportingDetailsAndroid, + ); + expect(api.launchExternalLinkAndroid).toBe(IAP.launchExternalLinkAndroid); + expect(api.showBillingProgramInformationDialogAndroid).toBe( + IAP.showBillingProgramInformationDialogAndroid, + ); + expect(api.showInAppMessagesAndroid).toBe(IAP.showInAppMessagesAndroid); + }); + it('reconnect uses Android billing config', async () => { let api: any; const Harness = () => { diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 38ffd1131..aca063dc3 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -57,10 +57,23 @@ const mockIap: any = { billingProgram: 'external-offer', isAvailable: true, })), + getBillingChoiceInfoAndroid: jest.fn(async () => ({ + playBillingChoiceImageUrl: 'https://play.google.com/billing-choice.png', + playBillingLoyaltyInfo: null, + })), createBillingProgramReportingDetailsAndroid: jest.fn(async () => ({ billingProgram: 'external-offer', externalTransactionToken: 'mock-token-123', })), + showBillingProgramInformationDialogAndroid: jest.fn(async () => ({ + responseCode: 0, + debugMessage: null, + subResponseCode: 'no-applicable-sub-response-code', + })), + showInAppMessagesAndroid: jest.fn(async () => ({ + responseCode: 'no-action-needed', + purchaseToken: null, + })), launchExternalLinkAndroid: jest.fn(async () => true), }; @@ -401,6 +414,18 @@ describe('Public API (src/index.ts)', () => { expect(mockIap.endConnection).toHaveBeenCalled(); }); + it('passes developer-rendered Billing Choice config to native', async () => { + (Platform as any).OS = 'android'; + const config = { + billingChoiceScreenTypeAndroid: 'developer-rendered', + enableBillingProgramAndroid: 'billing-choice', + } as const; + + await IAP.initConnection(config); + + expect(mockIap.initConnection).toHaveBeenCalledWith(config); + }); + it('listeners work after endConnection → initConnection reconnection', async () => { // 1. Initial connection + listener await IAP.initConnection(); @@ -748,6 +773,56 @@ describe('Public API (src/index.ts)', () => { ).toBeUndefined(); }); + it('Android forwards minimal in-app Billing Choice options', async () => { + (Platform as any).OS = 'android'; + await IAP.requestPurchase({ + request: { + google: { + skus: ['premium'], + developerBillingOption: { + billingProgram: 'billing-choice', + }, + }, + }, + type: 'in-app', + }); + + const [lastCallArgs] = mockIap.requestPurchase.mock.lastCall; + expect(lastCallArgs.android.developerBillingOption).toEqual({ + billingProgram: 'billing-choice', + }); + }); + + it('Android forwards Billing Choice subscription replacement fields', async () => { + (Platform as any).OS = 'android'; + await IAP.requestPurchase({ + request: { + google: { + skus: ['premium_monthly'], + originalExternalTransactionId: 'original-external-id', + developerBillingOption: { + billingProgram: 'billing-choice', + externalTransactionToken: 'pre-generated-token', + launchMode: 'caller-will-launch-link', + linkUri: 'https://example.com/checkout', + }, + }, + }, + type: 'subs', + }); + + const [lastCallArgs] = mockIap.requestPurchase.mock.lastCall; + expect(lastCallArgs.android.originalExternalTransactionId).toBe( + 'original-external-id', + ); + expect(lastCallArgs.android.developerBillingOption).toEqual({ + billingProgram: 'billing-choice', + externalTransactionToken: 'pre-generated-token', + launchMode: 'caller-will-launch-link', + linkUri: 'https://example.com/checkout', + }); + }); + it('Android subs supports all replacement modes', async () => { (Platform as any).OS = 'android'; const replacementModes = [ @@ -2057,6 +2132,14 @@ describe('Public API (src/index.ts)', () => { ); }); + it('should support billing-choice program (9.1.0+)', () => { + (Platform as any).OS = 'android'; + IAP.enableBillingProgramAndroid('billing-choice'); + expect(mockIap.enableBillingProgramAndroid).toHaveBeenCalledWith( + 'billing-choice', + ); + }); + it('should warn and return early on non-Android', () => { (Platform as any).OS = 'ios'; IAP.enableBillingProgramAndroid('external-offer'); @@ -2152,6 +2235,43 @@ describe('Public API (src/index.ts)', () => { }); }); + describe('getBillingChoiceInfoAndroid', () => { + it('should request Billing Choice info with defaults on Android', async () => { + (Platform as any).OS = 'android'; + const result = await IAP.getBillingChoiceInfoAndroid({}); + + expect(mockIap.getBillingChoiceInfoAndroid).toHaveBeenCalledWith({ + billingProgram: 'billing-choice', + playBillingChoiceImageLayout: 'rectangular-four-by-one', + userLocale: null, + }); + expect(result.playBillingChoiceImageUrl).toBe( + 'https://play.google.com/billing-choice.png', + ); + }); + + it('should request Billing Choice info with defaults when params are omitted', async () => { + (Platform as any).OS = 'android'; + const result = await (IAP.getBillingChoiceInfoAndroid as any)(); + + expect(mockIap.getBillingChoiceInfoAndroid).toHaveBeenCalledWith({ + billingProgram: 'billing-choice', + playBillingChoiceImageLayout: 'rectangular-four-by-one', + userLocale: null, + }); + expect(result.playBillingChoiceImageUrl).toBe( + 'https://play.google.com/billing-choice.png', + ); + }); + + it('should throw on non-Android', async () => { + (Platform as any).OS = 'ios'; + await expect(IAP.getBillingChoiceInfoAndroid({})).rejects.toThrow( + 'Billing Choice API is only supported on Android', + ); + }); + }); + describe('createBillingProgramReportingDetailsAndroid', () => { it('should return reporting details with token on Android', async () => { (Platform as any).OS = 'android'; @@ -2169,7 +2289,7 @@ describe('Public API (src/index.ts)', () => { expect( mockIap.createBillingProgramReportingDetailsAndroid, - ).toHaveBeenCalledWith('external-offer'); + ).toHaveBeenCalledWith('external-offer', null); expect(result.billingProgram).toBe('external-offer'); expect(result.externalTransactionToken).toBe('token-abc-123'); }); @@ -2179,6 +2299,9 @@ describe('Public API (src/index.ts)', () => { await expect( IAP.createBillingProgramReportingDetailsAndroid('external-offer'), ).rejects.toThrow('Billing Programs API is only supported on Android'); + expect( + mockIap.createBillingProgramReportingDetailsAndroid, + ).not.toHaveBeenCalled(); }); it('should handle native errors', async () => { @@ -2208,6 +2331,81 @@ describe('Public API (src/index.ts)', () => { expect(result.billingProgram).toBe('external-content-link'); expect(result.externalTransactionToken).toBe('content-token-456'); }); + + it('should pass developerBillingType for Billing Choice reporting details', async () => { + (Platform as any).OS = 'android'; + mockIap.createBillingProgramReportingDetailsAndroid.mockResolvedValueOnce( + { + billingProgram: 'billing-choice', + externalTransactionToken: 'choice-token-789', + }, + ); + + const result = await IAP.createBillingProgramReportingDetailsAndroid( + 'billing-choice', + 'external-link', + ); + + expect( + mockIap.createBillingProgramReportingDetailsAndroid, + ).toHaveBeenCalledWith('billing-choice', 'external-link'); + expect(result.billingProgram).toBe('billing-choice'); + }); + }); + + describe('showBillingProgramInformationDialogAndroid', () => { + it('should show Billing Choice information dialog with default program', async () => { + (Platform as any).OS = 'android'; + const result = + await IAP.showBillingProgramInformationDialogAndroid({ + externalTransactionToken: 'choice-token-123', + }); + + expect( + mockIap.showBillingProgramInformationDialogAndroid, + ).toHaveBeenCalledWith({ + billingProgram: 'billing-choice', + externalTransactionToken: 'choice-token-123', + }); + expect(result.responseCode).toBe(0); + expect(result.subResponseCode).toBe( + 'no-applicable-sub-response-code', + ); + }); + + it('should throw on non-Android', async () => { + (Platform as any).OS = 'ios'; + await expect( + IAP.showBillingProgramInformationDialogAndroid({ + externalTransactionToken: 'choice-token-123', + }), + ).rejects.toThrow('Billing Choice API is only supported on Android'); + expect( + mockIap.showBillingProgramInformationDialogAndroid, + ).not.toHaveBeenCalled(); + }); + }); + + describe('showInAppMessagesAndroid', () => { + it('should delegate to native in-app messages method', async () => { + (Platform as any).OS = 'android'; + const result = await IAP.showInAppMessagesAndroid({ + categories: ['transactional'], + }); + + expect(mockIap.showInAppMessagesAndroid).toHaveBeenCalledWith({ + categories: ['transactional'], + }); + expect(result.responseCode).toBe('no-action-needed'); + }); + + it('should throw on non-Android', async () => { + (Platform as any).OS = 'ios'; + await expect( + IAP.showInAppMessagesAndroid({categories: ['transactional']}), + ).rejects.toThrow('In-app messages are only supported on Android'); + expect(mockIap.showInAppMessagesAndroid).not.toHaveBeenCalled(); + }); }); describe('launchExternalLinkAndroid', () => { @@ -2233,6 +2431,19 @@ describe('Public API (src/index.ts)', () => { expect(result).toBe(true); }); + it('forwards Billing Choice external transaction token', async () => { + (Platform as any).OS = 'android'; + const params = { + ...defaultParams, + billingProgram: 'billing-choice' as const, + externalTransactionToken: 'external-token', + }; + + await IAP.launchExternalLinkAndroid(params); + + expect(mockIap.launchExternalLinkAndroid).toHaveBeenCalledWith(params); + }); + it('should return false when user declines', async () => { (Platform as any).OS = 'android'; mockIap.launchExternalLinkAndroid.mockResolvedValueOnce(false); diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index 59637e05b..71f6546a0 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -233,8 +233,7 @@ describe('Amazon Vega adapter', () => { const result = module.finishTransaction({ android: {purchaseToken: 'receipt-1', isConsumable: true}, }); - await Promise.resolve(); - jest.advanceTimersByTime(1_000); + await jest.advanceTimersByTimeAsync(1_000); await expect(result).resolves.toEqual( expect.objectContaining({ @@ -252,6 +251,30 @@ describe('Amazon Vega adapter', () => { } }); + it('times out Amazon Vega fulfillment without duplicating the request', async () => { + jest.useFakeTimers(); + const service = createService(); + service.notifyFulfillment.mockImplementationOnce( + () => new Promise(() => {}), + ); + const module = createVegaIapModule(service); + + try { + const result = module.finishTransaction({ + android: {purchaseToken: 'receipt-1', isConsumable: true}, + }); + const expectation = expect(result).rejects.toMatchObject({ + code: 'service-timeout', + }); + await jest.advanceTimersByTimeAsync(2_000); + + await expectation; + expect(service.notifyFulfillment).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + it('recovers fulfillable receipts after Amazon Vega purchase failures', async () => { const service = createService(); service.purchase.mockResolvedValueOnce({ diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index e80234b05..965b5d376 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -24,7 +24,14 @@ import { checkAlternativeBillingAvailabilityAndroid, showAlternativeBillingDialogAndroid, createAlternativeBillingTokenAndroid, + getBillingChoiceInfoAndroid, + isBillingProgramAvailableAndroid, + createBillingProgramReportingDetailsAndroid, + launchExternalLinkAndroid, + showBillingProgramInformationDialogAndroid, + showInAppMessagesAndroid, userChoiceBillingListenerAndroid, + developerProvidedBillingListenerAndroid, subscriptionBillingIssueListener, isStandardIOS, } from '../'; @@ -35,7 +42,9 @@ import type { ProductQueryType, RequestPurchaseProps, AlternativeBillingModeAndroid, + BillingChoiceScreenTypeAndroid, BillingProgramAndroid, + DeveloperProvidedBillingDetailsAndroid, UserChoiceBillingDetails, VerifyPurchaseProps, VerifyPurchaseResult, @@ -50,6 +59,8 @@ import type { PurchaseError, PurchaseUpdatedListenerOptions, ProductSubscription, + MutationField, + QueryField, } from '../types'; import type {MutationFinishTransactionArgs} from '../types'; @@ -265,6 +276,18 @@ type UseIap = { createAlternativeBillingTokenAndroid?: ( sku?: string, ) => Promise; + /** Fetch assets and loyalty text for a developer-rendered Billing Choice screen. */ + getBillingChoiceInfoAndroid?: QueryField<'getBillingChoiceInfoAndroid'>; + /** Check Billing Program availability and the configured choice renderer. */ + isBillingProgramAvailableAndroid?: MutationField<'isBillingProgramAvailableAndroid'>; + /** Create Google Play reporting details for a developer billing transaction. */ + createBillingProgramReportingDetailsAndroid?: MutationField<'createBillingProgramReportingDetailsAndroid'>; + /** Launch a supported Billing Program external link. */ + launchExternalLinkAndroid?: MutationField<'launchExternalLinkAndroid'>; + /** Show the required Billing Choice information dialog. */ + showBillingProgramInformationDialogAndroid?: MutationField<'showBillingProgramInformationDialogAndroid'>; + /** Show Play billing in-app messages. */ + showInAppMessagesAndroid?: MutationField<'showInAppMessagesAndroid'>; }; export interface UseIapOptions { @@ -280,6 +303,13 @@ export interface UseIapOptions { onError?: (error: Error) => void; onPromotedProductIOS?: (product: Product) => void; onUserChoiceBillingAndroid?: (details: UserChoiceBillingDetails) => void; + /** + * Fires when the user selects developer-provided billing in an External + * Payments or Google-rendered Billing Choice flow. + */ + onDeveloperProvidedBillingAndroid?: ( + details: DeveloperProvidedBillingDetailsAndroid, + ) => void; /** * Fires when an active subscription enters a billing-issue state * (StoreKit 2 Message.billingIssue on iOS 18+, Purchase.isSuspended on @@ -301,8 +331,14 @@ export interface UseIapOptions { * Use 'user-choice-billing' for User Choice Billing (7.0+). * Use 'external-offer' for External Offer program. * Use 'external-payments' for Developer Provided Billing (Japan only, 8.3.0+). + * Use 'billing-choice' for Billing Choice (9.1.0+). */ enableBillingProgramAndroid?: BillingProgramAndroid; + /** + * Select who renders the Billing Choice screen (9.1.0+). Must match the + * choiceScreenType returned by isBillingProgramAvailableAndroid. + */ + billingChoiceScreenTypeAndroid?: BillingChoiceScreenTypeAndroid; } /** @@ -356,6 +392,7 @@ export function useIAP(options?: UseIapOptions): UseIap { purchaseError?: EventSubscription; promotedProductIOS?: EventSubscription; userChoiceBillingAndroid?: EventSubscription; + developerProvidedBillingAndroid?: EventSubscription; subscriptionBillingIssue?: EventSubscription; }>({}); @@ -565,6 +602,7 @@ export function useIAP(options?: UseIapOptions): UseIap { | { enableBillingProgramAndroid?: BillingProgramAndroid; alternativeBillingModeAndroid?: AlternativeBillingModeAndroid; + billingChoiceScreenTypeAndroid?: BillingChoiceScreenTypeAndroid; } | undefined; @@ -573,6 +611,12 @@ export function useIAP(options?: UseIapOptions): UseIap { config = { enableBillingProgramAndroid: optionsRef.current.enableBillingProgramAndroid, + ...(optionsRef.current.billingChoiceScreenTypeAndroid + ? { + billingChoiceScreenTypeAndroid: + optionsRef.current.billingChoiceScreenTypeAndroid, + } + : {}), }; } else if (optionsRef.current?.alternativeBillingModeAndroid) { config = { @@ -644,6 +688,17 @@ export function useIAP(options?: UseIapOptions): UseIap { }); } + if ( + Platform.OS === 'android' && + optionsRef.current?.onDeveloperProvidedBillingAndroid && + !subscriptionsRef.current.developerProvidedBillingAndroid + ) { + subscriptionsRef.current.developerProvidedBillingAndroid = + developerProvidedBillingListenerAndroid((details) => { + optionsRef.current?.onDeveloperProvidedBillingAndroid?.(details); + }); + } + // Always attach so callers that supply `onSubscriptionBillingIssue` later // (after the hook has already set up listeners) still receive events. if (!subscriptionsRef.current.subscriptionBillingIssue) { @@ -660,11 +715,13 @@ export function useIAP(options?: UseIapOptions): UseIap { subscriptionsRef.current.purchaseError?.remove(); subscriptionsRef.current.promotedProductIOS?.remove(); subscriptionsRef.current.userChoiceBillingAndroid?.remove(); + subscriptionsRef.current.developerProvidedBillingAndroid?.remove(); subscriptionsRef.current.subscriptionBillingIssue?.remove(); subscriptionsRef.current.purchaseUpdate = undefined; subscriptionsRef.current.purchaseError = undefined; subscriptionsRef.current.promotedProductIOS = undefined; subscriptionsRef.current.userChoiceBillingAndroid = undefined; + subscriptionsRef.current.developerProvidedBillingAndroid = undefined; subscriptionsRef.current.subscriptionBillingIssue = undefined; }, []); @@ -763,6 +820,12 @@ export function useIAP(options?: UseIapOptions): UseIap { checkAlternativeBillingAvailabilityAndroid, showAlternativeBillingDialogAndroid, createAlternativeBillingTokenAndroid, + getBillingChoiceInfoAndroid, + isBillingProgramAvailableAndroid, + createBillingProgramReportingDetailsAndroid, + launchExternalLinkAndroid, + showBillingProgramInformationDialogAndroid, + showInAppMessagesAndroid, } : {}), }; diff --git a/libraries/react-native-iap/src/index.kepler.ts b/libraries/react-native-iap/src/index.kepler.ts index 9e02e91ea..2f5c8d62e 100644 --- a/libraries/react-native-iap/src/index.kepler.ts +++ b/libraries/react-native-iap/src/index.kepler.ts @@ -293,12 +293,21 @@ export const createAlternativeBillingTokenAndroid: MutationField< export const isBillingProgramAvailableAndroid: MutationField< 'isBillingProgramAvailableAndroid' > = async () => unsupported('isBillingProgramAvailableAndroid'); +export const getBillingChoiceInfoAndroid: QueryField< + 'getBillingChoiceInfoAndroid' +> = async () => unsupported('getBillingChoiceInfoAndroid'); export const launchExternalLinkAndroid: MutationField< 'launchExternalLinkAndroid' > = async () => unsupported('launchExternalLinkAndroid'); export const createBillingProgramReportingDetailsAndroid: MutationField< 'createBillingProgramReportingDetailsAndroid' > = async () => unsupported('createBillingProgramReportingDetailsAndroid'); +export const showBillingProgramInformationDialogAndroid: MutationField< + 'showBillingProgramInformationDialogAndroid' +> = async () => unsupported('showBillingProgramInformationDialogAndroid'); +export const showInAppMessagesAndroid: MutationField< + 'showInAppMessagesAndroid' +> = async () => unsupported('showInAppMessagesAndroid'); export const userChoiceBillingListenerAndroid = (): EventSubscription => ({ remove: () => {}, }); diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 2079d129d..982af0d72 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -18,11 +18,6 @@ import {ErrorCode} from './types'; import type { AndroidSubscriptionOfferInput, DiscountOfferInputIOS, - ExternalPurchaseCustomLinkNoticeResultIOS, - ExternalPurchaseCustomLinkNoticeTypeIOS, - ExternalPurchaseCustomLinkTokenResultIOS, - ExternalPurchaseCustomLinkTokenTypeIOS, - ExternalPurchaseNoticeResultIOS, FetchProductsResult, MutationField, Product, @@ -73,7 +68,17 @@ import {getVegaIapModule, isVegaOS} from './vega'; // 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 { + BillingChoiceInfoAndroid, + BillingProgramAndroid, + BillingProgramInformationDialogParamsAndroid, + BillingProgramReportingDetailsAndroid, + BillingResultAndroid, + DeveloperBillingTypeAndroid, + GetBillingChoiceInfoParamsAndroid, + InAppMessageParamsAndroid, + InAppMessageResultAndroid, +} from './types'; // Export all types export type { @@ -621,12 +626,11 @@ export const userChoiceBillingListenerAndroid = ( }; /** - * Add a listener for developer provided billing events (Android 8.3.0+ only). - * Fires when a user selects developer billing in the External Payments flow. + * Add a listener for developer provided billing events (Android 8.3.0+). + * Fires for External Payments and Billing Choice developer billing flows. * - * External Payments is part of Google Play Billing Library 8.3.0+ and allows - * showing a side-by-side choice between Google Play Billing and developer's - * external payment option directly in the purchase flow. (Japan only) + * The payload includes selected products and nullable token, link, and original + * transaction fields. Billing Choice fields require Billing Library 9.1.0+. * * @param listener - Function to call when user chooses developer billing * @returns EventSubscription with remove() method to unsubscribe @@ -636,14 +640,10 @@ export const userChoiceBillingListenerAndroid = ( * @example * ```typescript * const subscription = developerProvidedBillingListenerAndroid((details) => { - * console.log('User chose developer billing'); - * console.log('External transaction token received; send it to your backend without logging it.'); - * - * // Process payment through your external payment system - * await processExternalPayment(); - * - * // Report transaction to Google Play (within 24 hours) - * await reportToGooglePlay(details.externalTransactionToken); + * await processExternalPayment(details.products, details.linkUri); + * if (details.externalTransactionToken) { + * await reportToGooglePlay(details.externalTransactionToken); + * } * }); * * // Later, remove the listener @@ -854,8 +854,7 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { if (normalizedType === 'all') { const converted = (await fetchAndConvert('all')) as ( - | Product - | ProductSubscription + Product | ProductSubscription )[]; RnIapConsole.debug( @@ -1621,6 +1620,10 @@ export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( * ```ts * await initConnection(); * await initConnection({ enableBillingProgramAndroid: 'external-offer' }); + * await initConnection({ + * enableBillingProgramAndroid: 'billing-choice', + * billingChoiceScreenTypeAndroid: 'developer-rendered', + * }); * ``` * * @remarks When using `useIAP()`, connection is auto-managed on mount/unmount — @@ -1843,6 +1846,10 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( if (androidRequest.isOfferPersonalized != null) { androidPayload.isOfferPersonalized = androidRequest.isOfferPersonalized; } + if (androidRequest.developerBillingOption) { + androidPayload.developerBillingOption = + androidRequest.developerBillingOption; + } // One-time purchase offerToken (Android 7.0+) if (!isSubs) { @@ -1857,6 +1864,10 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( if (subsRequest.purchaseToken) { androidPayload.purchaseToken = subsRequest.purchaseToken; } + if (subsRequest.originalExternalTransactionId) { + androidPayload.originalExternalTransactionId = + subsRequest.originalExternalTransactionId; + } if (subsRequest.replacementMode != null) { androidPayload.replacementMode = subsRequest.replacementMode; } @@ -2394,41 +2405,42 @@ export const presentCodeRedemptionSheetIOS: MutationField< * * @see {@link https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios} */ -export const requestPurchaseOnPromotedProductIOS = - async (): Promise => { - if (Platform.OS !== 'ios') { - throw new Error( - 'requestPurchaseOnPromotedProductIOS is only available on iOS', - ); - } - - try { - await IAP.instance.buyPromotedProductIOS(); - const pending = await IAP.instance.getPendingTransactionsIOS(); - const latest = pending.find((purchase) => purchase != null); - if (!latest) { - throw new Error('No promoted purchase available after request'); - } +export const requestPurchaseOnPromotedProductIOS: MutationField< + 'requestPurchaseOnPromotedProductIOS' +> = async () => { + if (Platform.OS !== 'ios') { + throw new Error( + 'requestPurchaseOnPromotedProductIOS is only available on iOS', + ); + } - const converted = convertNitroPurchaseToPurchase(latest); - if (converted.platform !== 'ios') { - throw new Error('Promoted purchase result not available for iOS'); - } + try { + await IAP.instance.buyPromotedProductIOS(); + const pending = await IAP.instance.getPendingTransactionsIOS(); + const latest = pending.find((purchase) => purchase != null); + if (!latest) { + throw new Error('No promoted purchase available after request'); + } - return true; - } catch (error) { - const parsedError = parseErrorAndLogIfNeeded( - '[requestPurchaseOnPromotedProductIOS] Failed:', - error, - ); - throw createPurchaseError({ - code: parsedError.code, - message: parsedError.message, - responseCode: parsedError.responseCode, - debugMessage: parsedError.debugMessage, - }); + const converted = convertNitroPurchaseToPurchase(latest); + if (converted.platform !== 'ios') { + throw new Error('Promoted purchase result not available for iOS'); } - }; + + return true; + } catch (error) { + const parsedError = parseErrorAndLogIfNeeded( + '[requestPurchaseOnPromotedProductIOS] Failed:', + error, + ); + throw createPurchaseError({ + code: parsedError.code, + message: parsedError.message, + responseCode: parsedError.responseCode, + debugMessage: parsedError.debugMessage, + }); + } +}; /** * Clear unfinished transactions on iOS @@ -2925,7 +2937,9 @@ export const isBillingProgramAvailableAndroid: MutationField< const result = await IAP.instance.isBillingProgramAvailableAndroid(program); return { billingProgram: result.billingProgram as unknown as BillingProgramAndroid, + choiceScreenType: result.choiceScreenType, isAvailable: result.isAvailable, + isExternalLinkAvailable: result.isExternalLinkAvailable, }; } catch (error) { RnIapConsole.error('Failed to check billing program availability:', error); @@ -2933,6 +2947,37 @@ export const isBillingProgramAvailableAndroid: MutationField< } }; +/** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens (Android only). + * + * @param params - Billing Choice info request parameters + * @returns Promise with Play-hosted image URL and optional loyalty information + * @platform Android + * @since Google Play Billing Library 9.1.0+ + * + * @see {@link https://openiap.dev/docs/apis/android/get-billing-choice-info-android} + */ +export const getBillingChoiceInfoAndroid: QueryField< + 'getBillingChoiceInfoAndroid' +> = async ( + params: GetBillingChoiceInfoParamsAndroid = {}, +): Promise => { + if (Platform.OS !== 'android') { + throw new Error('Billing Choice API is only supported on Android'); + } + try { + return await IAP.instance.getBillingChoiceInfoAndroid({ + billingProgram: params.billingProgram ?? 'billing-choice', + playBillingChoiceImageLayout: + params.playBillingChoiceImageLayout ?? 'rectangular-four-by-one', + userLocale: params.userLocale ?? null, + }); + } catch (error) { + RnIapConsole.error('Failed to get Billing Choice info:', error); + throw error; + } +}; + /** * Create billing program reporting details for external transactions (Android only). * Used to get the external transaction token needed for reporting to Google. @@ -2954,15 +2999,34 @@ export const isBillingProgramAvailableAndroid: MutationField< * * @see {@link https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android} */ -export const createBillingProgramReportingDetailsAndroid: MutationField< - 'createBillingProgramReportingDetailsAndroid' -> = async (program) => { +export const createBillingProgramReportingDetailsAndroid: MutationField<'createBillingProgramReportingDetailsAndroid'> & + (( + program: BillingProgramAndroid, + developerBillingType?: DeveloperBillingTypeAndroid | null, + ) => Promise) = async ( + programOrArgs: + | BillingProgramAndroid + | { + program: BillingProgramAndroid; + developerBillingType?: DeveloperBillingTypeAndroid | null; + }, + developerBillingType?: DeveloperBillingTypeAndroid | null, +): Promise => { if (Platform.OS !== 'android') { throw new Error('Billing Programs API is only supported on Android'); } try { + const program = + typeof programOrArgs === 'string' ? programOrArgs : programOrArgs.program; + const resolvedDeveloperBillingType = + typeof programOrArgs === 'string' + ? developerBillingType + : (programOrArgs.developerBillingType ?? developerBillingType); const result = - await IAP.instance.createBillingProgramReportingDetailsAndroid(program); + await IAP.instance.createBillingProgramReportingDetailsAndroid( + program, + resolvedDeveloperBillingType ?? null, + ); return { billingProgram: result.billingProgram as unknown as BillingProgramAndroid, externalTransactionToken: result.externalTransactionToken, @@ -2977,7 +3041,67 @@ export const createBillingProgramReportingDetailsAndroid: MutationField< }; /** - * Launch external link for external offers or app download (Android only). + * Show Google's mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen (Android only). + * + * @param params - Dialog parameters with the external transaction token + * @returns Promise with BillingResult + * @platform Android + * @since Google Play Billing Library 9.1.0+ + * + * @see {@link https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android} + */ +export const showBillingProgramInformationDialogAndroid: MutationField< + 'showBillingProgramInformationDialogAndroid' +> = async ( + params: BillingProgramInformationDialogParamsAndroid, +): Promise => { + if (Platform.OS !== 'android') { + throw new Error('Billing Choice API is only supported on Android'); + } + try { + return await IAP.instance.showBillingProgramInformationDialogAndroid({ + billingProgram: params.billingProgram ?? 'billing-choice', + externalTransactionToken: params.externalTransactionToken, + }); + } catch (error) { + RnIapConsole.error( + 'Failed to show Billing Choice information dialog:', + error, + ); + throw error; + } +}; + +/** + * Show Play Billing in-app messages, such as transactional subscription updates (Android only). + * + * @param params - Optional in-app message categories + * @returns Promise with in-app message result + * @platform Android + * @since Google Play Billing Library 4.1.0+ + * + * @see {@link https://openiap.dev/docs/apis/android/show-in-app-messages-android} + */ +export const showInAppMessagesAndroid: MutationField< + 'showInAppMessagesAndroid' +> = async ( + params?: InAppMessageParamsAndroid | null, +): Promise => { + if (Platform.OS !== 'android') { + throw new Error('In-app messages are only supported on Android'); + } + try { + return await IAP.instance.showInAppMessagesAndroid(params ?? null); + } catch (error) { + RnIapConsole.error('Failed to show in-app messages:', error); + throw error; + } +}; + +/** + * Launch an external link for Billing Programs (Android only). Developer-rendered + * Billing Choice external-link flows require 9.1.0+ and a pre-generated token. * * @param params - Parameters for launching the external link * @returns Promise - true if user accepted, false otherwise @@ -2987,7 +3111,8 @@ export const createBillingProgramReportingDetailsAndroid: MutationField< * @example * ```typescript * const success = await launchExternalLinkAndroid({ - * billingProgram: 'external-offer', + * billingProgram: 'billing-choice', + * externalTransactionToken: reportingDetails.externalTransactionToken, * launchMode: 'launch-in-external-browser-or-app', * linkType: 'link-to-digital-content-offer', * linkUri: 'https://your-website.com/purchase' @@ -3008,6 +3133,7 @@ export const launchExternalLinkAndroid: MutationField< try { return await IAP.instance.launchExternalLinkAndroid({ billingProgram: params.billingProgram, + externalTransactionToken: params.externalTransactionToken, launchMode: params.launchMode, linkType: params.linkType, linkUri: params.linkUri, @@ -3078,21 +3204,22 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< * * @see {@link https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios} */ -export const presentExternalPurchaseNoticeSheetIOS = - async (): Promise => { - if (Platform.OS !== 'ios') { - throw new Error('External purchase is only supported on iOS'); - } - try { - return await IAP.instance.presentExternalPurchaseNoticeSheetIOS(); - } catch (error) { - RnIapConsole.error( - 'Failed to present external purchase notice sheet:', - error, - ); - throw error; - } - }; +export const presentExternalPurchaseNoticeSheetIOS: MutationField< + 'presentExternalPurchaseNoticeSheetIOS' +> = async () => { + if (Platform.OS !== 'ios') { + throw new Error('External purchase is only supported on iOS'); + } + try { + return await IAP.instance.presentExternalPurchaseNoticeSheetIOS(); + } catch (error) { + RnIapConsole.error( + 'Failed to present external purchase notice sheet:', + error, + ); + throw error; + } +}; /** * Present an external purchase link to redirect users to your website (iOS 16.0+). @@ -3147,21 +3274,22 @@ export const presentExternalPurchaseLinkIOS: MutationField< * * @see {@link https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios} */ -export const isEligibleForExternalPurchaseCustomLinkIOS = - async (): Promise => { - if (Platform.OS !== 'ios') { - return false; - } - try { - return await IAP.instance.isEligibleForExternalPurchaseCustomLinkIOS(); - } catch (error) { - RnIapConsole.error( - 'Failed to check external purchase custom link eligibility:', - error, - ); - return false; - } - }; +export const isEligibleForExternalPurchaseCustomLinkIOS: QueryField< + 'isEligibleForExternalPurchaseCustomLinkIOS' +> = async () => { + if (Platform.OS !== 'ios') { + return false; + } + try { + return await IAP.instance.isEligibleForExternalPurchaseCustomLinkIOS(); + } catch (error) { + RnIapConsole.error( + 'Failed to check external purchase custom link eligibility:', + error, + ); + return false; + } +}; /** * Get external purchase token for reporting to Apple (iOS 18.1+). @@ -3184,9 +3312,9 @@ export const isEligibleForExternalPurchaseCustomLinkIOS = * * @see {@link https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios} */ -export const getExternalPurchaseCustomLinkTokenIOS = async ( - tokenType: ExternalPurchaseCustomLinkTokenTypeIOS, -): Promise => { +export const getExternalPurchaseCustomLinkTokenIOS: QueryField< + 'getExternalPurchaseCustomLinkTokenIOS' +> = async (tokenType) => { if (Platform.OS !== 'ios') { throw new Error( 'External purchase custom link is only supported on iOS 18.1+', @@ -3224,9 +3352,9 @@ export const getExternalPurchaseCustomLinkTokenIOS = async ( * * @see {@link https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios} */ -export const showExternalPurchaseCustomLinkNoticeIOS = async ( - noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS, -): Promise => { +export const showExternalPurchaseCustomLinkNoticeIOS: MutationField< + 'showExternalPurchaseCustomLinkNoticeIOS' +> = async (noticeType) => { if (Platform.OS !== 'ios') { throw new Error( 'External purchase custom link is only supported on iOS 18.1+', diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index 005425b07..79e9cf180 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -35,6 +35,7 @@ import type { ExternalPurchaseCustomLinkTokenTypeIOS, ExternalPurchaseLinkResultIOS, ExternalPurchaseNoticeResultIOS, + DeveloperBillingOptionParamsAndroid, DeveloperProvidedBillingDetailsAndroid, MutationFinishTransactionArgs, ProductCommon, @@ -53,6 +54,7 @@ import type { UserChoiceBillingDetails, PaymentModeIOS, SubscriptionProductReplacementParamsAndroid, + SubResponseCodeAndroid, WinBackOfferInputIOS, } from '../types'; @@ -96,13 +98,38 @@ export type PurchaseVerificationProvider = 'iapkit' | 'none'; // Billing Programs API (Android) // GQL type exists but defined locally for Nitro codegen consistency -// Android 8.2.0+, 8.3.0+ for external-payments, 7.0+ for user-choice-billing +// Android 8.2.0+, 8.3.0+ for external-payments, 9.1.0+ for billing-choice, +// 7.0+ for user-choice-billing export type BillingProgramAndroid = | 'unspecified' | 'external-content-link' | 'external-offer' | 'external-payments' - | 'user-choice-billing'; + | 'user-choice-billing' + | 'billing-choice'; + +export type BillingChoiceImageLayoutAndroid = + | 'rectangular-four-by-one' + | 'rectangular-three-by-one' + | 'rectangular-two-by-two'; + +export type BillingChoiceScreenTypeAndroid = + | 'unspecified' + | 'developer-rendered' + | 'google-rendered'; + +export type DeveloperBillingTypeAndroid = + | 'developer-billing-type-unspecified' + | 'in-app' + | 'external-link'; + +export type InAppMessageCategoryAndroid = + | 'unknown-in-app-message-category-id' + | 'transactional'; + +export type InAppMessageResponseCodeAndroid = + | 'no-action-needed' + | 'subscription-status-updated'; // Developer Billing Launch Mode (Android 8.3.0+) // Defined locally for Nitro codegen @@ -218,6 +245,10 @@ export interface NitroRequestPurchaseAndroid { /** @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) */ replacementMode?: RequestSubscriptionAndroidProps['replacementMode']; purchaseToken?: RequestSubscriptionAndroidProps['purchaseToken']; + /** Original external transaction ID for developer-billed subscription replacement (9.1.0+). */ + originalExternalTransactionId?: RequestSubscriptionAndroidProps['originalExternalTransactionId']; + /** Developer billing option for External Payments (8.3.0+) or Billing Choice (9.1.0+). */ + developerBillingOption?: DeveloperBillingOptionParamsAndroid | null; /** * Product-level replacement parameters (8.1.0+) * Use this instead of replacementMode for item-level replacement @@ -298,8 +329,10 @@ export interface NitroDeepLinkOptionsAndroid { * Parameters for launching an external link (Android 8.2.0+) */ export interface NitroLaunchExternalLinkParamsAndroid { - /** The billing program (external-content-link or external-offer) */ + /** The billing program (external-content-link, external-offer, or billing-choice) */ billingProgram: BillingProgramAndroid; + /** Reporting token for a developer-rendered Billing Choice external-link flow (9.1.0+). */ + externalTransactionToken?: string | null; /** The external link launch mode */ launchMode: ExternalLinkLaunchModeAndroid; /** The type of the external link */ @@ -308,6 +341,21 @@ export interface NitroLaunchExternalLinkParamsAndroid { linkUri: string; } +export interface NitroGetBillingChoiceInfoParamsAndroid { + billingProgram: BillingProgramAndroid; + playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid; + userLocale?: string | null; +} + +export interface NitroBillingProgramInformationDialogParamsAndroid { + billingProgram: BillingProgramAndroid; + externalTransactionToken: string; +} + +export interface NitroInAppMessageParamsAndroid { + categories?: InAppMessageCategoryAndroid[] | null; +} + // ╔══════════════════════════════════════════════════════════════════════════╗ // ║ TYPES ║ // ╚══════════════════════════════════════════════════════════════════════════╝ @@ -344,6 +392,22 @@ export interface NitroPurchaseResult { purchaseToken?: string; } +export interface NitroBillingResultAndroid { + responseCode: number; + debugMessage?: string | null; + subResponseCode?: SubResponseCodeAndroid | null; +} + +export interface NitroBillingChoiceInfoAndroid { + playBillingChoiceImageUrl: string; + playBillingLoyaltyInfo?: string | null; +} + +export interface NitroInAppMessageResultAndroid { + responseCode: InAppMessageResponseCodeAndroid; + purchaseToken?: string | null; +} + export interface NitroReceiptValidationResultIOS { isValid: boolean; receiptData: string; @@ -428,8 +492,12 @@ export interface NitroVerifyPurchaseWithProviderResult { export interface NitroBillingProgramAvailabilityResultAndroid { /** The billing program that was checked */ billingProgram: BillingProgramAndroid; + /** Billing Choice screen renderer. Populated only for available Billing Choice results. */ + choiceScreenType?: BillingChoiceScreenTypeAndroid | null; /** Whether the billing program is available for the user */ isAvailable: boolean; + /** Whether external-link payment is available for Billing Choice. */ + isExternalLinkAvailable?: boolean | null; } /** @@ -1090,12 +1158,11 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { ): void; /** - * Add a listener for developer provided billing events (Android 8.3.0+ only). - * Fires when a user selects developer billing in the External Payments flow. + * Add a listener for developer provided billing events (Android 8.3.0+). + * Fires for External Payments and Billing Choice developer billing flows. * - * External Payments is part of Google Play Billing Library 8.3.0+ and allows - * showing a side-by-side choice between Google Play Billing and developer's - * external payment option directly in the purchase flow. (Japan only) + * Billing Choice expands the payload with nullable link/original transaction + * fields and selected products in Billing Library 9.1.0+. * * @param listener - Function to call when user chooses developer billing * @platform Android @@ -1165,6 +1232,18 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { program: BillingProgramAndroid, ): Promise; + /** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + * + * @param params - Billing Choice info request parameters + * @returns Promise with the Play-hosted image URL and optional loyalty info + * @platform Android + * @since Billing Library 9.1.0+ + */ + getBillingChoiceInfoAndroid( + params: NitroGetBillingChoiceInfoParamsAndroid, + ): Promise; + /** * Create billing program reporting details for external transactions (Android only). * Used to get the external transaction token needed for reporting to Google. @@ -1176,8 +1255,33 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { */ createBillingProgramReportingDetailsAndroid( program: BillingProgramAndroid, + developerBillingType?: DeveloperBillingTypeAndroid | null, ): Promise; + /** + * Show the Play-provided Billing Choice information dialog. + * + * @param params - Billing Choice dialog parameters + * @returns Promise with BillingResult + * @platform Android + * @since Billing Library 9.1.0+ + */ + showBillingProgramInformationDialogAndroid( + params: NitroBillingProgramInformationDialogParamsAndroid, + ): Promise; + + /** + * Show Play Billing in-app messages, such as transactional subscription updates. + * + * @param params - Optional in-app message categories + * @returns Promise with in-app message result + * @platform Android + * @since Billing Library 4.1.0+ + */ + showInAppMessagesAndroid( + params?: NitroInAppMessageParamsAndroid | null, + ): Promise; + /** * Launch external link for external offers or app download (Android only). * diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 6d2d9b825..e36ce758d 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -26,6 +26,7 @@ export interface ActiveSubscription { * pending upgrades/downgrades, and auto-renewal preferences. */ renewalInfoIOS?: (RenewalInfoIOS | null); + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId: string; /** @@ -118,10 +119,35 @@ export interface AppTransaction { } /** - * Billing program types for external content links, external offers, and external payments (Android) - * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 + * Play Billing choice image layout (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ -export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'external-content-link' | 'external-offer' | 'external-payments'; +export type BillingChoiceImageLayoutAndroid = 'rectangular-four-by-one' | 'rectangular-three-by-one' | 'rectangular-two-by-two'; + +/** + * Display information for developer-rendered Billing Choice screens (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface BillingChoiceInfoAndroid { + /** URL for the Play Billing choice image matching the requested layout. */ + playBillingChoiceImageUrl: string; + /** Play Loyalty information for the user. */ + playBillingLoyaltyInfo?: (string | null); +} + +/** + * Choice screen renderer for Billing Choice availability (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export type BillingChoiceScreenTypeAndroid = 'unspecified' | 'developer-rendered' | 'google-rendered'; + +/** + * Billing program types for Google Play Billing Programs (Android) + * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, + * BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). + */ +export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'external-content-link' | 'external-offer' | 'external-payments' | 'billing-choice'; /** * Result of checking billing program availability (Android) @@ -130,8 +156,30 @@ export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'ext export interface BillingProgramAvailabilityResultAndroid { /** The billing program that was checked */ billingProgram: BillingProgramAndroid; + /** + * Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + choiceScreenType?: (BillingChoiceScreenTypeAndroid | null); /** Whether the billing program is available for the user */ isAvailable: boolean; + /** + * Whether external-link payment is available for Billing Choice. + * Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + isExternalLinkAvailable?: (boolean | null); +} + +/** + * Parameters for showing a billing program information dialog (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface BillingProgramInformationDialogParamsAndroid { + /** Billing program. Currently only BILLING_CHOICE is supported. */ + billingProgram?: BillingProgramAndroid; + /** External transaction token returned by the Billing Choice reporting-details flow. */ + externalTransactionToken: string; } /** @@ -180,19 +228,38 @@ export interface DeepLinkOptions { export type DeveloperBillingLaunchModeAndroid = 'unspecified' | 'launch-in-external-browser-or-app' | 'caller-will-launch-link'; /** - * Parameters for developer billing option in purchase flow (Android) - * Used with BillingFlowParams to enable external payments flow - * Available in Google Play Billing Library 8.3.0+ + * Parameters for a developer billing option in a purchase flow (Android). + * Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). + * Only billingProgram is required; link fields are used when the selected program + * links outside the app. */ export interface DeveloperBillingOptionParamsAndroid { - /** The billing program (should be EXTERNAL_PAYMENTS for external payments flow) */ + /** The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. */ billingProgram: BillingProgramAndroid; - /** The launch mode for the external payment link */ - launchMode: DeveloperBillingLaunchModeAndroid; - /** The URI where the external payment will be processed */ - linkUri: string; + /** + * A pre-generated external transaction token for a Billing Choice external-link + * flow. Omit it when Google Play should provide the token in the callback. + */ + externalTransactionToken?: (string | null); + /** + * The launch mode for the external payment link. + * Required only when the selected billing program links outside the app. + */ + launchMode?: (DeveloperBillingLaunchModeAndroid | null); + /** + * The URI where the external payment will be processed. + * Required only when the selected billing program links outside the app. + */ + linkUri?: (string | null); } +/** + * Developer-provided billing destination type for Billing Program reporting details (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export type DeveloperBillingTypeAndroid = 'developer-billing-type-unspecified' | 'in-app' | 'external-link'; + /** * Details provided when user selects developer billing option (Android) * Received via DeveloperProvidedBillingListener callback @@ -201,10 +268,31 @@ export interface DeveloperBillingOptionParamsAndroid { export interface DeveloperProvidedBillingDetailsAndroid { /** * External transaction token used to report transactions made through developer billing. - * This token must be used when reporting the external transaction to Google Play. - * Must be reported within 24 hours of the transaction. + * Nullable for flows such as external payments where no token is returned. */ - externalTransactionToken: string; + externalTransactionToken?: (string | null); + /** + * URI to launch for an external-link Billing Choice flow, when provided by + * Google Play. + */ + linkUri?: (string | null); + /** + * Original external transaction ID when replacing a subscription that was + * purchased through developer billing. + */ + originalExternalTransactionId?: (string | null); + /** Products selected for the developer billing flow. */ + products: DeveloperProvidedBillingProductAndroid[]; +} + +/** Product selected for developer-provided billing (Android 9.0+). */ +export interface DeveloperProvidedBillingProductAndroid { + /** Product identifier. */ + id: string; + /** Subscription offer token, when applicable. */ + offerToken?: (string | null); + /** Google Play product type (in-app or subscription). */ + type: ProductType; } /** @@ -507,6 +595,19 @@ export interface ExternalPurchaseNoticeResultIOS { export type FetchProductsResult = ProductOrSubscription[] | Product[] | ProductSubscription[] | null; +/** + * Parameters for fetching Billing Choice display information (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface GetBillingChoiceInfoParamsAndroid { + /** Billing program. Currently only BILLING_CHOICE is supported. */ + billingProgram?: BillingProgramAndroid; + /** Desired Play Billing choice image layout. */ + playBillingChoiceImageLayout?: BillingChoiceImageLayoutAndroid; + /** BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. */ + userLocale?: (string | null); +} + export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android' | 'subscription-billing-issue'; export type IapPlatform = 'ios' | 'android'; @@ -516,6 +617,42 @@ 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'; +/** + * High-level in-app message category (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export type InAppMessageCategoryAndroid = 'unknown-in-app-message-category-id' | 'transactional'; + +/** + * Parameters for showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export interface InAppMessageParamsAndroid { + /** In-app message categories to show. Defaults to transactional messages. */ + categories?: (InAppMessageCategoryAndroid[] | null); +} + +/** + * Response code from Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export type InAppMessageResponseCodeAndroid = 'no-action-needed' | 'subscription-status-updated'; + +/** + * Result from showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export interface InAppMessageResultAndroid { + /** Purchase token returned when a subscription status changed. */ + purchaseToken?: (string | null); + /** Response code for the in-app messaging flow. */ + responseCode: InAppMessageResponseCodeAndroid; +} + /** Connection initialization configuration */ export interface InitConnectionConfig { /** @@ -525,6 +662,16 @@ export interface InitConnectionConfig { * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. */ alternativeBillingModeAndroid?: (AlternativeBillingModeAndroid | null); + /** + * Billing Choice renderer configured in Play Console. Available in OpenIAP + * Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + * can emit the selection event. DEVELOPER_RENDERED omits that listener so the + * app can render its own choice screen and use the reporting/dialog/link APIs. + * Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + * Defaults to GOOGLE_RENDERED. + */ + billingChoiceScreenTypeAndroid?: (BillingChoiceScreenTypeAndroid | null); /** * Enable a specific billing program for Android (7.0+) * When set, enables the specified billing program for external transactions. @@ -532,6 +679,8 @@ export interface InitConnectionConfig { * - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) * - EXTERNAL_OFFER: External offers for digital content (8.2.0+) * - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + * - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) */ enableBillingProgramAndroid?: (BillingProgramAndroid | null); } @@ -559,12 +708,19 @@ export interface InstallmentPlanDetailsAndroid { /** * Parameters for launching an external link (Android) - * Used with launchExternalLink to initiate external offer or app install flows + * Used with launchExternalLink to initiate external offer, app install, or + * developer-rendered Billing Choice flows * Available in Google Play Billing Library 8.2.0+ */ export interface LaunchExternalLinkParamsAndroid { - /** The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) */ + /** The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) */ billingProgram: BillingProgramAndroid; + /** + * External transaction token for a developer-rendered Billing Choice external-link + * flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + */ + externalTransactionToken?: (string | null); /** The external link launch mode */ launchMode: ExternalLinkLaunchModeAndroid; /** The type of the external link */ @@ -597,7 +753,6 @@ export interface Mutation { beginRefundRequestIOS?: Promise<(string | null)>; /** * Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -617,7 +772,6 @@ export interface Mutation { * Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. * Must be called AFTER successful payment in your payment system. * Token must be reported to Google Play backend within 24 hours. - * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -626,8 +780,11 @@ export interface Mutation { /** * Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). * Replaces the deprecated createExternalOfferReportingDetailsAsync API. - * * Returns external transaction token needed for reporting external transactions. + * developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + * native Android defaults it to IN_APP. + * The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ @@ -655,7 +812,6 @@ export interface Mutation { /** * Check whether a billing program (e.g., External Payments) is available for the current user. * Replaces the deprecated isExternalOfferAvailableAsync API. - * * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. @@ -663,9 +819,11 @@ export interface Mutation { */ isBillingProgramAvailableAndroid: Promise; /** - * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + * including developer-rendered Billing Choice external-link flows. + * Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). * Replaces the deprecated showExternalOfferInformationDialog API. - * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -711,12 +869,19 @@ export interface Mutation { /** * Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. * Must be called BEFORE processing payment in your payment system. - * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ showAlternativeBillingDialogAndroid: Promise; + /** + * Show Google's mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + */ + showBillingProgramInformationDialogAndroid: Promise; /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. @@ -724,6 +889,15 @@ export interface Mutation { * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ showExternalPurchaseCustomLinkNoticeIOS: Promise; + /** + * Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + * Returns a response code and, when the subscription status changes, the related purchase token. + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + */ + showInAppMessagesAndroid: Promise; /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios @@ -766,7 +940,11 @@ export type MutationBeginRefundRequestIosArgs = string; export type MutationConsumePurchaseAndroidArgs = string; -export type MutationCreateBillingProgramReportingDetailsAndroidArgs = BillingProgramAndroid; +export interface MutationCreateBillingProgramReportingDetailsAndroidArgs { + developerBillingType?: (DeveloperBillingTypeAndroid | null); + program: BillingProgramAndroid; +} + export type MutationDeepLinkToSubscriptionsArgs = (DeepLinkOptions | null) | undefined; @@ -801,8 +979,12 @@ export type MutationRequestPurchaseArgs = }; +export type MutationShowBillingProgramInformationDialogAndroidArgs = BillingProgramInformationDialogParamsAndroid; + export type MutationShowExternalPurchaseCustomLinkNoticeIosArgs = ExternalPurchaseCustomLinkNoticeTypeIOS; +export type MutationShowInAppMessagesAndroidArgs = (InAppMessageParamsAndroid | null) | undefined; + export type MutationValidateReceiptArgs = VerifyPurchaseProps; export type MutationVerifyPurchaseArgs = VerifyPurchaseProps; @@ -1197,6 +1379,7 @@ export interface PurchaseAndroid extends PurchaseCommon { signatureAndroid?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId?: (string | null); } @@ -1221,6 +1404,7 @@ export interface PurchaseCommon { quantity: number; /** Store where purchase was made */ store: IapStore; + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; } @@ -1278,6 +1462,7 @@ export interface PurchaseIOS extends PurchaseCommon { store: IapStore; storefrontCountryCodeIOS?: (string | null); subscriptionGroupIdIOS?: (string | null); + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId: string; transactionReasonIOS?: (string | null); @@ -1359,6 +1544,13 @@ export interface Query { * See: https://openiap.dev/docs/apis/get-available-purchases */ getAvailablePurchases: Promise; + /** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client is not ready. + * See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + */ + getBillingChoiceInfoAndroid: Promise; /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. @@ -1447,6 +1639,8 @@ export type QueryGetActiveSubscriptionsArgs = (string[] | null) | undefined; export type QueryGetAvailablePurchasesArgs = (PurchaseOptions | null) | undefined; +export type QueryGetBillingChoiceInfoAndroidArgs = GetBillingChoiceInfoParamsAndroid; + export type QueryGetExternalPurchaseCustomLinkTokenIosArgs = ExternalPurchaseCustomLinkTokenTypeIOS; export type QueryGetTransactionJwsIosArgs = string; @@ -1546,9 +1740,9 @@ export interface RentalDetailsAndroid { export interface RequestPurchaseAndroidProps { /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ developerBillingOption?: (DeveloperBillingOptionParamsAndroid | null); /** @@ -1633,9 +1827,9 @@ export type RequestPurchaseResult = Purchase | Purchase[] | null; export interface RequestSubscriptionAndroidProps { /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ developerBillingOption?: (DeveloperBillingOptionParamsAndroid | null); /** @@ -1647,6 +1841,12 @@ export interface RequestSubscriptionAndroidProps { obfuscatedAccountId?: (string | null); /** Obfuscated profile ID */ obfuscatedProfileId?: (string | null); + /** + * Original external transaction ID for replacing a subscription that was + * purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + originalExternalTransactionId?: (string | null); /** Purchase token for upgrades/downgrades */ purchaseToken?: (string | null); /** @@ -1782,11 +1982,11 @@ export type SubResponseCodeAndroid = 'no-applicable-sub-response-code' | 'paymen export interface Subscription { /** - * Fires when a user selects developer billing in the External Payments flow (Android only) - * Triggered when the user chooses to pay via the developer's external payment option - * instead of Google Play Billing in the side-by-side choice dialog. - * Contains the externalTransactionToken needed to report the transaction. - * Available in Google Play Billing Library 8.3.0+ + * Fires when a user selects developer billing in an External Payments or + * Billing Choice flow (Android only). The payload can contain an external + * transaction token, link URI, original transaction ID, and selected products. + * Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ developerProvidedBillingAndroid: DeveloperProvidedBillingDetailsAndroid; /** Fires when the App Store surfaces a promoted product (iOS only) */ @@ -2235,6 +2435,7 @@ export type QueryArgsMap = { getAllTransactionsIOS: never; getAppTransactionIOS: never; getAvailablePurchases: QueryGetAvailablePurchasesArgs; + getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; getPendingTransactionsIOS: never; getPromotedProductIOS: never; @@ -2285,7 +2486,9 @@ export type MutationArgsMap = { requestPurchaseOnPromotedProductIOS: never; restorePurchases: never; showAlternativeBillingDialogAndroid: never; + showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidArgs; showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIosArgs; + showInAppMessagesAndroid: MutationShowInAppMessagesAndroidArgs; showManageSubscriptionsIOS: never; syncIOS: never; validateReceipt: MutationValidateReceiptArgs; diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index 843849f1e..3ca42bcff 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -21,6 +21,7 @@ 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_ATTEMPT_TIMEOUT_MS = 2_000; const NOTIFY_FULFILLMENT_MAX_ATTEMPTS = 15; const NOTIFY_FULFILLMENT_RETRY_DELAY_MS = 1_000; const PURCHASE_UPDATES_MAX_ATTEMPTS = 5; @@ -207,6 +208,26 @@ function delay(ms: number): Promise { }); } +function withTimeout( + operation: Promise, + timeoutMs: number, + timeoutError: Error, +): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => reject(timeoutError), timeoutMs); + operation.then( + (value) => { + clearTimeout(timeoutId); + resolve(value); + }, + (error) => { + clearTimeout(timeoutId); + reject(error); + }, + ); + }); +} + function isSuccess( operation: ResponseOperation, responseCode: unknown, @@ -860,10 +881,17 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { attempt <= NOTIFY_FULFILLMENT_MAX_ATTEMPTS; attempt += 1 ) { - const response = await service.notifyFulfillment({ - fulfillmentResult: FULFILLMENT_RESULT_FULFILLED, - receiptId: purchaseToken, - }); + const response = await withTimeout( + service.notifyFulfillment({ + fulfillmentResult: FULFILLMENT_RESULT_FULFILLED, + receiptId: purchaseToken, + }), + NOTIFY_FULFILLMENT_ATTEMPT_TIMEOUT_MS, + createVegaError( + ErrorCode.ServiceTimeout, + 'Amazon Vega notifyFulfillment timed out.', + ), + ); if (isSuccess('notify-fulfillment', response?.responseCode)) { return { responseCode: 0, @@ -872,7 +900,6 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { purchaseToken, }; } - lastResponse = response; if (attempt < NOTIFY_FULFILLMENT_MAX_ATTEMPTS) { await delay(NOTIFY_FULFILLMENT_RETRY_DELAY_MS); @@ -909,7 +936,8 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { const purchaseTimestamp = toTimestamp(receipt.purchaseDate); if ( options?.minPurchaseDateMs != null && - (purchaseTimestamp === 0 || purchaseTimestamp < options.minPurchaseDateMs) + (purchaseTimestamp === 0 || + purchaseTimestamp < options.minPurchaseDateMs) ) { continue; } @@ -1286,11 +1314,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { if (!response.receipt) return []; cachedUserData = response.userData ?? cachedUserData; - const purchase = mapReceipt( - response.receipt, - fallbackProductType, - sku, - ); + const purchase = mapReceipt(response.receipt, fallbackProductType, sku); emitPurchaseUpdated(purchase); return [purchase]; } catch (error) { @@ -1469,11 +1493,22 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { async isBillingProgramAvailableAndroid(): Promise { return throwUnsupportedFeature('isBillingProgramAvailableAndroid'); }, + async getBillingChoiceInfoAndroid(): Promise { + return throwUnsupportedFeature('getBillingChoiceInfoAndroid'); + }, async createBillingProgramReportingDetailsAndroid(): Promise { return throwUnsupportedFeature( 'createBillingProgramReportingDetailsAndroid', ); }, + async showBillingProgramInformationDialogAndroid(): Promise { + return throwUnsupportedFeature( + 'showBillingProgramInformationDialogAndroid', + ); + }, + async showInAppMessagesAndroid(): Promise { + return throwUnsupportedFeature('showInAppMessagesAndroid'); + }, async launchExternalLinkAndroid(): Promise { return throwUnsupportedFeature('launchExternalLinkAndroid'); }, diff --git a/openiap-versions.json b/openiap-versions.json index 5a40ed41c..6217eb621 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,5 +1,5 @@ { - "spec": "2.0.3", + "spec": "2.1.0", "google": "2.3.0-rc.1", "apple": "2.2.5-rc.1" } diff --git a/packages/apple/CONVENTION.md b/packages/apple/CONVENTION.md new file mode 100644 index 000000000..b6dd5562e --- /dev/null +++ b/packages/apple/CONVENTION.md @@ -0,0 +1,38 @@ +# Apple Package Conventions + +This file captures package-local rules for `packages/apple`. The root +`AGENTS.md` and `knowledge/internal/` files remain the source of truth. + +## Naming + +- iOS-specific public APIs must end with `IOS`. +- Cross-platform APIs must not add a platform suffix. +- Keep Swift acronyms in repo style: `OpenIAP` for the package name, but + `OpenIapModule`, `OpenIapStore`, and `Iap*` when the acronym appears at the + beginning or middle of a symbol. + +## Generated Types + +- Do not edit `Sources/Models/Types.swift` directly. +- Change GraphQL schema in `packages/gql/src`, run generation, then sync the + generated output through the documented monorepo scripts. + +## Public API Changes + +- When `Sources/OpenIapModule.swift` adds or changes a public async API, update + `Sources/OpenIapModule+ObjC.swift` in the same change. +- Keep `Sources/OpenIapProtocol.swift`, `OpenIapModule`, `OpenIapStore`, and + generated handler wiring consistent for any exposed operation. +- Prefer StoreKit 2 async/await APIs and put conversion logic in + `Sources/Helpers/StoreKitTypesBridge.swift` rather than duplicating mapping + code in call sites. + +## Verification + +Run these checks after Apple package changes: + +```bash +cd packages/apple +swift build +swift test --filter OpenIapTests +``` diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 656556304..500c35895 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -24,8 +24,32 @@ public enum AlternativeBillingModeAndroid: String, Codable, CaseIterable { case alternativeOnly = "alternative-only" } -/// Billing program types for external content links, external offers, and external payments (Android) -/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +/// Play Billing choice image layout (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public enum BillingChoiceImageLayoutAndroid: String, Codable, CaseIterable { + /// Rectangular image with a 4:1 aspect ratio. + case rectangularFourByOne = "rectangular-four-by-one" + /// Rectangular image with a 3:1 aspect ratio. + case rectangularThreeByOne = "rectangular-three-by-one" + /// Rectangular image with a 2:2 aspect ratio. + case rectangularTwoByTwo = "rectangular-two-by-two" +} + +/// Choice screen renderer for Billing Choice availability (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public enum BillingChoiceScreenTypeAndroid: String, Codable, CaseIterable { + /// Unspecified choice screen type. + case unspecified = "unspecified" + /// Choice screen is rendered by the developer app. + case developerRendered = "developer-rendered" + /// Choice screen is rendered by Google Play. + case googleRendered = "google-rendered" +} + +/// Billing program types for Google Play Billing Programs (Android) +/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, +/// BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (requires Play Billing 9.1.0+). public enum BillingProgramAndroid: String, Codable, CaseIterable { /// Unspecified billing program. Do not use. case unspecified = "unspecified" @@ -46,6 +70,10 @@ public enum BillingProgramAndroid: String, Codable, CaseIterable { /// Users can choose to complete the purchase on the developer's website. /// Available in Google Play Billing Library 8.3.0+ case externalPayments = "external-payments" + /// Billing Choice program. + /// Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + case billingChoice = "billing-choice" } /// Launch mode for developer billing option (Android) @@ -62,6 +90,17 @@ public enum DeveloperBillingLaunchModeAndroid: String, Codable, CaseIterable { case callerWillLaunchLink = "caller-will-launch-link" } +/// Developer-provided billing destination type for Billing Program reporting details (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public enum DeveloperBillingTypeAndroid: String, Codable, CaseIterable { + /// Unspecified developer billing type. Do not use. + case developerBillingTypeUnspecified = "developer-billing-type-unspecified" + /// Developer-provided billing via native in-app experience. + case inApp = "in-app" + /// Developer-provided billing via external link or embedded web browsing. + case externalLink = "external-link" +} + /// Discount offer type enumeration. /// Categorizes the type of discount or promotional offer. public enum DiscountOfferType: String, Codable, CaseIterable { @@ -261,8 +300,9 @@ public enum IapEvent: String, Codable, CaseIterable { case purchaseError = "purchase-error" case promotedProductIos = "promoted-product-ios" case userChoiceBillingAndroid = "user-choice-billing-android" - /// Fired when user selects developer-provided billing option in external payments flow. - /// Available on Android with Google Play Billing Library 8.3.0+ + /// Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + /// developer billing selections on Android. Billing Choice is available in + /// OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). case developerProvidedBillingAndroid = "developer-provided-billing-android" /// Fired when an active subscription enters a billing-issue state that requires user attention. /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and @@ -306,6 +346,26 @@ public enum IapStore: String, Codable, CaseIterable { case amazon = "amazon" } +/// High-level in-app message category (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public enum InAppMessageCategoryAndroid: String, Codable, CaseIterable { + /// Unknown in-app message category. + case unknownInAppMessageCategoryId = "unknown-in-app-message-category-id" + /// Transactional billing messages, such as payment issues or pending price-change confirmations. + case transactional = "transactional" +} + +/// Response code from Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public enum InAppMessageResponseCodeAndroid: String, Codable, CaseIterable { + /// Flow finished and no developer action is needed. + case noActionNeeded = "no-action-needed" + /// Subscription status changed and the purchase token should be checked. + case subscriptionStatusUpdated = "subscription-status-updated" +} + /// Payment mode for subscription offers. /// Determines how the user pays during the offer period. public enum PaymentMode: String, Codable, CaseIterable { @@ -573,6 +633,7 @@ public protocol PurchaseCommon: Codable { var quantity: Int { get } /// Store where purchase was made var store: IapStore { get } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. var transactionDate: Double { get } } @@ -597,6 +658,7 @@ public struct ActiveSubscription: Codable { /// Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, /// pending upgrades/downgrades, and auto-renewal preferences. public var renewalInfoIOS: RenewalInfoIOS? = nil + /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double public var transactionId: String /// @deprecated iOS only - use daysUntilExpirationIOS instead. @@ -667,13 +729,29 @@ public struct AppTransaction: Codable { public var signedDate: Double } +/// Display information for developer-rendered Billing Choice screens (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public struct BillingChoiceInfoAndroid: Codable { + /// URL for the Play Billing choice image matching the requested layout. + public var playBillingChoiceImageUrl: String + /// Play Loyalty information for the user. + public var playBillingLoyaltyInfo: String? = nil +} + /// Result of checking billing program availability (Android) /// Available in Google Play Billing Library 8.2.0+ public struct BillingProgramAvailabilityResultAndroid: Codable { /// The billing program that was checked public var billingProgram: BillingProgramAndroid + /// Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + public var choiceScreenType: BillingChoiceScreenTypeAndroid? = nil /// Whether the billing program is available for the user public var isAvailable: Bool + /// Whether external-link payment is available for Billing Choice. + /// Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + public var isExternalLinkAvailable: Bool? = nil } /// Reporting details for transactions made outside of Google Play Billing (Android) @@ -704,9 +782,26 @@ public struct BillingResultAndroid: Codable { /// Available in Google Play Billing Library 8.3.0+ public struct DeveloperProvidedBillingDetailsAndroid: Codable { /// External transaction token used to report transactions made through developer billing. - /// This token must be used when reporting the external transaction to Google Play. - /// Must be reported within 24 hours of the transaction. - public var externalTransactionToken: String + /// Nullable for flows such as external payments where no token is returned. + public var externalTransactionToken: String? = nil + /// URI to launch for an external-link Billing Choice flow, when provided by + /// Google Play. + public var linkUri: String? = nil + /// Original external transaction ID when replacing a subscription that was + /// purchased through developer billing. + public var originalExternalTransactionId: String? = nil + /// Products selected for the developer billing flow. + public var products: [DeveloperProvidedBillingProductAndroid] +} + +/// Product selected for developer-provided billing (Android 9.0+). +public struct DeveloperProvidedBillingProductAndroid: Codable { + /// Product identifier. + public var id: String + /// Subscription offer token, when applicable. + public var offerToken: String? = nil + /// Google Play product type (in-app or subscription). + public var type: ProductType } /// Discount amount details for one-time purchase offers (Android) @@ -879,6 +974,16 @@ public enum FetchProductsResult { case subscriptions([ProductSubscription]?) } +/// Result from showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public struct InAppMessageResultAndroid: Codable { + /// Purchase token returned when a subscription status changed. + public var purchaseToken: String? = nil + /// Response code for the in-app messaging flow. + public var responseCode: InAppMessageResponseCodeAndroid +} + /// Installment plan details for subscription offers (Android) /// Contains information about the installment plan commitment. /// Available in Google Play Billing Library 7.0+ @@ -1154,6 +1259,7 @@ public struct PurchaseAndroid: Codable, PurchaseCommon { public var signatureAndroid: String? = nil /// Store where purchase was made public var store: IapStore + /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double public var transactionId: String? = nil } @@ -1209,6 +1315,7 @@ public struct PurchaseIOS: Codable, PurchaseCommon { public var store: IapStore public var storefrontCountryCodeIOS: String? = nil public var subscriptionGroupIdIOS: String? = nil + /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double public var transactionId: String public var transactionReasonIOS: String? = nil @@ -1549,6 +1656,23 @@ public struct AndroidSubscriptionOfferInput: Codable { } } +/// Parameters for showing a billing program information dialog (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public struct BillingProgramInformationDialogParamsAndroid: Codable { + /// Billing program. Currently only BILLING_CHOICE is supported. + public var billingProgram: BillingProgramAndroid + /// External transaction token returned by the Billing Choice reporting-details flow. + public var externalTransactionToken: String + + public init( + billingProgram: BillingProgramAndroid = .billingChoice, + externalTransactionToken: String + ) { + self.billingProgram = billingProgram + self.externalTransactionToken = externalTransactionToken + } +} + public struct DeepLinkOptions: Codable { /// Android package name to target (required on Android) public var packageNameAndroid: String? @@ -1564,23 +1688,32 @@ public struct DeepLinkOptions: Codable { } } -/// Parameters for developer billing option in purchase flow (Android) -/// Used with BillingFlowParams to enable external payments flow -/// Available in Google Play Billing Library 8.3.0+ +/// Parameters for a developer billing option in a purchase flow (Android). +/// Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice +/// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). +/// Only billingProgram is required; link fields are used when the selected program +/// links outside the app. public struct DeveloperBillingOptionParamsAndroid: Codable { - /// The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + /// The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. public var billingProgram: BillingProgramAndroid - /// The launch mode for the external payment link - public var launchMode: DeveloperBillingLaunchModeAndroid - /// The URI where the external payment will be processed - public var linkUri: String + /// A pre-generated external transaction token for a Billing Choice external-link + /// flow. Omit it when Google Play should provide the token in the callback. + public var externalTransactionToken: String? + /// The launch mode for the external payment link. + /// Required only when the selected billing program links outside the app. + public var launchMode: DeveloperBillingLaunchModeAndroid? + /// The URI where the external payment will be processed. + /// Required only when the selected billing program links outside the app. + public var linkUri: String? public init( billingProgram: BillingProgramAndroid, - launchMode: DeveloperBillingLaunchModeAndroid, - linkUri: String + externalTransactionToken: String? = nil, + launchMode: DeveloperBillingLaunchModeAndroid? = nil, + linkUri: String? = nil ) { self.billingProgram = billingProgram + self.externalTransactionToken = externalTransactionToken self.launchMode = launchMode self.linkUri = linkUri } @@ -1637,6 +1770,41 @@ public struct DiscountOfferInputIOS: Codable { } } +/// Parameters for fetching Billing Choice display information (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public struct GetBillingChoiceInfoParamsAndroid: Codable { + /// Billing program. Currently only BILLING_CHOICE is supported. + public var billingProgram: BillingProgramAndroid + /// Desired Play Billing choice image layout. + public var playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid + /// BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + public var userLocale: String? + + public init( + billingProgram: BillingProgramAndroid = .billingChoice, + playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid = .rectangularFourByOne, + userLocale: String? = nil + ) { + self.billingProgram = billingProgram + self.playBillingChoiceImageLayout = playBillingChoiceImageLayout + self.userLocale = userLocale + } +} + +/// Parameters for showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public struct InAppMessageParamsAndroid: Codable { + /// In-app message categories to show. Defaults to transactional messages. + public var categories: [InAppMessageCategoryAndroid]? + + public init( + categories: [InAppMessageCategoryAndroid]? = [.transactional] + ) { + self.categories = categories + } +} + /// Connection initialization configuration public struct InitConnectionConfig: Codable { /// Alternative billing mode for Android @@ -1644,29 +1812,46 @@ public struct InitConnectionConfig: Codable { /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. public var alternativeBillingModeAndroid: AlternativeBillingModeAndroid? + /// Billing Choice renderer configured in Play Console. Available in OpenIAP + /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + /// can emit the selection event. DEVELOPER_RENDERED omits that listener so the + /// app can render its own choice screen and use the reporting/dialog/link APIs. + /// Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + /// Defaults to GOOGLE_RENDERED. + public var billingChoiceScreenTypeAndroid: BillingChoiceScreenTypeAndroid? /// Enable a specific billing program for Android (7.0+) /// When set, enables the specified billing program for external transactions. /// - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) /// - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) /// - EXTERNAL_OFFER: External offers for digital content (8.2.0+) /// - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + /// - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + /// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) public var enableBillingProgramAndroid: BillingProgramAndroid? public init( alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = nil, + billingChoiceScreenTypeAndroid: BillingChoiceScreenTypeAndroid? = .googleRendered, enableBillingProgramAndroid: BillingProgramAndroid? = nil ) { self.alternativeBillingModeAndroid = alternativeBillingModeAndroid + self.billingChoiceScreenTypeAndroid = billingChoiceScreenTypeAndroid self.enableBillingProgramAndroid = enableBillingProgramAndroid } } /// Parameters for launching an external link (Android) -/// Used with launchExternalLink to initiate external offer or app install flows +/// Used with launchExternalLink to initiate external offer, app install, or +/// developer-rendered Billing Choice flows /// Available in Google Play Billing Library 8.2.0+ public struct LaunchExternalLinkParamsAndroid: Codable { - /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + /// The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) public var billingProgram: BillingProgramAndroid + /// External transaction token for a developer-rendered Billing Choice external-link + /// flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + public var externalTransactionToken: String? /// The external link launch mode public var launchMode: ExternalLinkLaunchModeAndroid /// The type of the external link @@ -1676,11 +1861,13 @@ public struct LaunchExternalLinkParamsAndroid: Codable { public init( billingProgram: BillingProgramAndroid, + externalTransactionToken: String? = nil, launchMode: ExternalLinkLaunchModeAndroid, linkType: ExternalLinkTypeAndroid, linkUri: String ) { self.billingProgram = billingProgram + self.externalTransactionToken = externalTransactionToken self.launchMode = launchMode self.linkType = linkType self.linkUri = linkUri @@ -1693,7 +1880,7 @@ public struct ProductRequest: Codable { public init( skus: [String], - type: ProductQueryType? = nil + type: ProductQueryType? = .inApp ) { self.skus = skus self.type = type @@ -1759,9 +1946,9 @@ public struct PurchaseUpdatedListenerOptions: Codable { } public struct RequestPurchaseAndroidProps: Codable { - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). public var developerBillingOption: DeveloperBillingOptionParamsAndroid? /// Personalized offer flag. /// When true, indicates the price was customized for this user. @@ -1930,9 +2117,9 @@ public struct RequestPurchasePropsByPlatforms: Codable { } public struct RequestSubscriptionAndroidProps: Codable { - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). public var developerBillingOption: DeveloperBillingOptionParamsAndroid? /// Personalized offer flag. /// When true, indicates the price was customized for this user. @@ -1941,6 +2128,10 @@ public struct RequestSubscriptionAndroidProps: Codable { public var obfuscatedAccountId: String? /// Obfuscated profile ID public var obfuscatedProfileId: String? + /// Original external transaction ID for replacing a subscription that was + /// purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). + public var originalExternalTransactionId: String? /// Purchase token for upgrades/downgrades public var purchaseToken: String? /// Replacement mode for subscription changes @@ -1959,6 +2150,7 @@ public struct RequestSubscriptionAndroidProps: Codable { isOfferPersonalized: Bool? = nil, obfuscatedAccountId: String? = nil, obfuscatedProfileId: String? = nil, + originalExternalTransactionId: String? = nil, purchaseToken: String? = nil, replacementMode: Int? = nil, skus: [String], @@ -1969,6 +2161,7 @@ public struct RequestSubscriptionAndroidProps: Codable { self.isOfferPersonalized = isOfferPersonalized self.obfuscatedAccountId = obfuscatedAccountId self.obfuscatedProfileId = obfuscatedProfileId + self.originalExternalTransactionId = originalExternalTransactionId self.purchaseToken = purchaseToken self.replacementMode = replacementMode self.skus = skus @@ -2570,6 +2763,7 @@ public enum Purchase: Codable, PurchaseCommon { } } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double { switch self { case let .purchaseAndroid(value): @@ -2597,7 +2791,6 @@ public protocol MutationResolver { /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios func beginRefundRequestIOS(_ sku: String) async throws -> String? /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -2611,18 +2804,20 @@ public protocol MutationResolver { /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. /// Token must be reported to Google Play backend within 24 hours. - /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android func createAlternativeBillingTokenAndroid() async throws -> String? /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. - /// /// Returns external transaction token needed for reporting external transactions. + /// developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + /// native Android defaults it to IN_APP. + /// The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android - func createBillingProgramReportingDetailsAndroid(_ program: BillingProgramAndroid) async throws -> BillingProgramReportingDetailsAndroid + func createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid?) async throws -> BillingProgramReportingDetailsAndroid /// Open the platform's subscription management UI. /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void @@ -2637,15 +2832,16 @@ public protocol MutationResolver { func initConnection(_ config: InitConnectionConfig?) async throws -> Bool /// Check whether a billing program (e.g., External Payments) is available for the current user. /// Replaces the deprecated isExternalOfferAvailableAsync API. - /// /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android func isBillingProgramAvailableAndroid(_ program: BillingProgramAndroid) async throws -> BillingProgramAvailabilityResultAndroid - /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + /// including developer-rendered Billing Choice external-link flows. + /// Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. - /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -2676,16 +2872,28 @@ public protocol MutationResolver { func restorePurchases() async throws -> Void /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. - /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android func showAlternativeBillingDialogAndroid() async throws -> Bool + /// Show Google's mandatory information dialog before a developer-rendered, + /// in-app Billing Choice screen. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + func showBillingProgramInformationDialogAndroid(_ params: BillingProgramInformationDialogParamsAndroid) async throws -> BillingResultAndroid /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios func showExternalPurchaseCustomLinkNoticeIOS(_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS + /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + /// (upstream API available since Play Billing 4.1.0). + /// Returns a response code and, when the subscription status changes, the related purchase token. + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + func showInAppMessagesAndroid(_ params: InAppMessageParamsAndroid?) async throws -> InAppMessageResultAndroid /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] @@ -2736,6 +2944,11 @@ public protocol QueryResolver { /// List active purchases for the current user. /// See: https://openiap.dev/docs/apis/get-available-purchases func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] + /// Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client is not ready. + /// See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + func getBillingChoiceInfoAndroid(_ params: GetBillingChoiceInfoParamsAndroid) async throws -> BillingChoiceInfoAndroid /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) @@ -2786,11 +2999,11 @@ public protocol QueryResolver { /// GraphQL root subscription operations. public protocol SubscriptionResolver { - /// Fires when a user selects developer billing in the External Payments flow (Android only) - /// Triggered when the user chooses to pay via the developer's external payment option - /// instead of Google Play Billing in the side-by-side choice dialog. - /// Contains the externalTransactionToken needed to report the transaction. - /// Available in Google Play Billing Library 8.3.0+ + /// Fires when a user selects developer billing in an External Payments or + /// Billing Choice flow (Android only). The payload can contain an external + /// transaction token, link URI, original transaction ID, and selected products. + /// Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). func developerProvidedBillingAndroid() async throws -> DeveloperProvidedBillingDetailsAndroid /// Fires when the App Store surfaces a promoted product (iOS only) func promotedProductIOS() async throws -> String @@ -2828,7 +3041,7 @@ public typealias MutationCheckAlternativeBillingAvailabilityAndroidHandler = () public typealias MutationClearTransactionIOSHandler = () async throws -> Bool public typealias MutationConsumePurchaseAndroidHandler = (_ purchaseToken: String) async throws -> Bool public typealias MutationCreateAlternativeBillingTokenAndroidHandler = () async throws -> String? -public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = (_ program: BillingProgramAndroid) async throws -> BillingProgramReportingDetailsAndroid +public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = (_ program: BillingProgramAndroid, _ developerBillingType: DeveloperBillingTypeAndroid?) async throws -> BillingProgramReportingDetailsAndroid public typealias MutationDeepLinkToSubscriptionsHandler = (_ options: DeepLinkOptions?) async throws -> Void public typealias MutationEndConnectionHandler = () async throws -> Bool public typealias MutationFinishTransactionHandler = (_ purchase: PurchaseInput, _ isConsumable: Bool?) async throws -> Void @@ -2842,7 +3055,9 @@ public typealias MutationRequestPurchaseHandler = (_ params: RequestPurchaseProp public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = () async throws -> Bool public typealias MutationRestorePurchasesHandler = () async throws -> Void public typealias MutationShowAlternativeBillingDialogAndroidHandler = () async throws -> Bool +public typealias MutationShowBillingProgramInformationDialogAndroidHandler = (_ params: BillingProgramInformationDialogParamsAndroid) async throws -> BillingResultAndroid public typealias MutationShowExternalPurchaseCustomLinkNoticeIOSHandler = (_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS +public typealias MutationShowInAppMessagesAndroidHandler = (_ params: InAppMessageParamsAndroid?) async throws -> InAppMessageResultAndroid public typealias MutationShowManageSubscriptionsIOSHandler = () async throws -> [PurchaseIOS] public typealias MutationSyncIOSHandler = () async throws -> Bool public typealias MutationValidateReceiptHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult @@ -2870,7 +3085,9 @@ public struct MutationHandlers { public var requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? public var restorePurchases: MutationRestorePurchasesHandler? public var showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? + public var showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidHandler? public var showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? + public var showInAppMessagesAndroid: MutationShowInAppMessagesAndroidHandler? public var showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? public var syncIOS: MutationSyncIOSHandler? public var validateReceipt: MutationValidateReceiptHandler? @@ -2898,7 +3115,9 @@ public struct MutationHandlers { requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = nil, restorePurchases: MutationRestorePurchasesHandler? = nil, showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = nil, + showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidHandler? = nil, showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? = nil, + showInAppMessagesAndroid: MutationShowInAppMessagesAndroidHandler? = nil, showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = nil, syncIOS: MutationSyncIOSHandler? = nil, validateReceipt: MutationValidateReceiptHandler? = nil, @@ -2925,7 +3144,9 @@ public struct MutationHandlers { self.requestPurchaseOnPromotedProductIOS = requestPurchaseOnPromotedProductIOS self.restorePurchases = restorePurchases self.showAlternativeBillingDialogAndroid = showAlternativeBillingDialogAndroid + self.showBillingProgramInformationDialogAndroid = showBillingProgramInformationDialogAndroid self.showExternalPurchaseCustomLinkNoticeIOS = showExternalPurchaseCustomLinkNoticeIOS + self.showInAppMessagesAndroid = showInAppMessagesAndroid self.showManageSubscriptionsIOS = showManageSubscriptionsIOS self.syncIOS = syncIOS self.validateReceipt = validateReceipt @@ -2943,6 +3164,7 @@ public typealias QueryGetActiveSubscriptionsHandler = (_ subscriptionIds: [Strin public typealias QueryGetAllTransactionsIOSHandler = () async throws -> [PurchaseIOS] public typealias QueryGetAppTransactionIOSHandler = () async throws -> AppTransaction? public typealias QueryGetAvailablePurchasesHandler = (_ options: PurchaseOptions?) async throws -> [Purchase] +public typealias QueryGetBillingChoiceInfoAndroidHandler = (_ params: GetBillingChoiceInfoParamsAndroid) async throws -> BillingChoiceInfoAndroid public typealias QueryGetExternalPurchaseCustomLinkTokenIOSHandler = (_ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) async throws -> ExternalPurchaseCustomLinkTokenResultIOS public typealias QueryGetPendingTransactionsIOSHandler = () async throws -> [PurchaseIOS] public typealias QueryGetPromotedProductIOSHandler = () async throws -> ProductIOS? @@ -2966,6 +3188,7 @@ public struct QueryHandlers { public var getAllTransactionsIOS: QueryGetAllTransactionsIOSHandler? public var getAppTransactionIOS: QueryGetAppTransactionIOSHandler? public var getAvailablePurchases: QueryGetAvailablePurchasesHandler? + public var getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidHandler? public var getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIOSHandler? public var getPendingTransactionsIOS: QueryGetPendingTransactionsIOSHandler? public var getPromotedProductIOS: QueryGetPromotedProductIOSHandler? @@ -2989,6 +3212,7 @@ public struct QueryHandlers { getAllTransactionsIOS: QueryGetAllTransactionsIOSHandler? = nil, getAppTransactionIOS: QueryGetAppTransactionIOSHandler? = nil, getAvailablePurchases: QueryGetAvailablePurchasesHandler? = nil, + getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidHandler? = nil, getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIOSHandler? = nil, getPendingTransactionsIOS: QueryGetPendingTransactionsIOSHandler? = nil, getPromotedProductIOS: QueryGetPromotedProductIOSHandler? = nil, @@ -3011,6 +3235,7 @@ public struct QueryHandlers { self.getAllTransactionsIOS = getAllTransactionsIOS self.getAppTransactionIOS = getAppTransactionIOS self.getAvailablePurchases = getAvailablePurchases + self.getBillingChoiceInfoAndroid = getBillingChoiceInfoAndroid self.getExternalPurchaseCustomLinkTokenIOS = getExternalPurchaseCustomLinkTokenIOS self.getPendingTransactionsIOS = getPendingTransactionsIOS self.getPromotedProductIOS = getPromotedProductIOS diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index c78987275..3a72193c4 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -408,7 +408,7 @@ import StoreKit store: .apple, storefrontCountryCodeIOS: nil, subscriptionGroupIdIOS: nil, - transactionDate: Date().timeIntervalSince1970, + transactionDate: Date().milliseconds, transactionId: purchaseId, transactionReasonIOS: nil, webOrderLineItemIdIOS: nil diff --git a/packages/docs/openiap-versions.json b/packages/docs/openiap-versions.json index 5a40ed41c..6217eb621 100644 --- a/packages/docs/openiap-versions.json +++ b/packages/docs/openiap-versions.json @@ -1,5 +1,5 @@ { - "spec": "2.0.3", + "spec": "2.1.0", "google": "2.3.0-rc.1", "apple": "2.2.5-rc.1" } diff --git a/packages/docs/package.json b/packages/docs/package.json index 28144ed5e..d54992ede 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,7 +1,7 @@ { "name": "@hyodotdev/openiap-docs", "private": true, - "version": "2.0.3", + "version": "2.1.0", "type": "module", "scripts": { "dev": "bunx vite", diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 60dd2f42d..aa595e6d3 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-07-04T10:52:01.940Z +> Generated: 2026-07-10T22:14:59.416Z ## Table of Contents 1. Installation @@ -31,10 +31,10 @@ cd ios && pod install ### Swift (iOS/macOS) ```swift // Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.4") +.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.5-rc.1") // CocoaPods -pod 'openiap', '~> 2.2.4' +pod 'openiap', '~> 2.2.5-rc.1' ``` ### Kotlin (Android) @@ -55,13 +55,13 @@ flutter pub add flutter_inapp_purchase ``` ### Godot -Download `godot-iap-2.4.0-rc.1.zip` from GitHub Releases, extract it to +Download `godot-iap-2.4.0-rc.3.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.4.0-rc.1") + implementation("io.github.hyochan:kmp-iap:2.4.0-rc.3") } ``` @@ -73,7 +73,7 @@ https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap dotnet add package OpenIap.Maui ``` -Current NuGet package version: 1.2.0-rc.1 +Current NuGet package version: 1.2.1-rc.2 Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. @@ -191,9 +191,7 @@ Canonical setup docs live under `/docs/setup/store`: Onside. In Expo config plugin options, use `modules.amazon.vegaOS=true`. Bare React Native Vega targets provide their own `manifest.toml`, Kepler package metadata, and runtime - dependencies. Check the Vega SDK release notes for the current supported - React Native for Vega version before choosing Kepler package versions: - https://developer.amazon.com/docs/vega/0.23/vega-release-notes.html + dependencies. `modules.amazon.fireOS` and `modules.amazon.vegaOS` can both be enabled when an app produces separate Fire OS and Vega OS artifacts. Required values: Vega `manifest.toml` package id, title, interactive @@ -231,10 +229,7 @@ uses direct Gradle flavor selection for Fire OS and a separate Kepler target for Vega. Install `@amazon-devices/keplerscript-appstore-iap-lib` and let `react-native-iap` / `expo-iap` select the `kepler` adapter at runtime, similar to how Onside -is selected at the runtime integration layer. Check the Vega SDK release notes -for the current supported React Native for Vega version before choosing Kepler -package versions: -https://developer.amazon.com/docs/vega/0.23/vega-release-notes.html +is selected at the runtime integration layer. --- @@ -689,7 +684,7 @@ await endConnection(); # Google Play Billing Library API Reference -> Reference documentation for Google Play Billing Library 8.x +> Reference documentation for Google Play Billing Library 9.x > Adapt all patterns to match OpenIAP internal conventions. ## Overview @@ -705,8 +700,15 @@ Google Play Billing Library enables in-app purchases and subscriptions on Androi | 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API | | 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` | | 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options | +| 9.0 | 2026-05-19 | Removes older deprecated APIs, reclassifies blocked Play Store activity errors, adds richer sub-response handling, target SDK 35 | +| 9.1 | 2026-06-18 | Billing Choice APIs: `getBillingChoiceInfoAsync()`, `showBillingProgramInformationDialog()`, choice-screen details | -**Current Version**: 8.3.0 (as of April 2026) +**Current Version**: 9.1.0 (as of July 2026) + +> **OpenIAP audit note**: `packages/google` is pinned to Play Billing 9.1.0. +> Billing Choice APIs are implemented only in the Play flavor; Horizon and +> Amazon variants keep unsupported/default behavior for APIs that do not exist +> in their store SDKs. ## Core Classes @@ -717,7 +719,11 @@ The main interface for communicating with Google Play Billing. ```kotlin val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) - .enablePendingPurchases() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) // New in 8.0: Auto-reconnect on service disconnect .enableAutoServiceReconnection() .build() @@ -734,7 +740,8 @@ BillingClient.newBuilder(context) When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors. -> **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed. +> **OpenIAP Note**: Auto-reconnection is enabled internally when the Play +> Billing version exposes the API. No OpenIAP app-level configuration is needed. ### Connection Management @@ -1020,11 +1027,11 @@ billingClient.queryPurchasesAsync(params) { billingResult, purchases -> ```kotlin val result = billingClient.launchBillingFlow(activity, params) -when (result.subResponseCode) { - BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> { +when (result.onPurchasesUpdatedSubResponseCode) { + BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -> { // User's payment method has insufficient funds } - BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> { + BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE -> { // User doesn't meet offer eligibility requirements } } @@ -1036,6 +1043,11 @@ when (result.subResponseCode) { | `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility | | `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies | +PBL 9 makes sub-response-code handling part of the migration checklist. It also +changes blocked Play Store app cases from generic `ERROR` to +`BILLING_UNAVAILABLE`, with a debug message explaining that Play Store is +blocked. + ## Subscription Product Replacement (8.1+) Product-level replacement parameters for subscription upgrades/downgrades: @@ -1074,10 +1086,15 @@ Billing Library 8.3 (December 2025) added support for the External Payments prog // During BillingClient setup val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) - .enablePendingPurchases() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) .enableAutoServiceReconnection() - .enableDeveloperBillingOption( - DeveloperBillingOptionParams.newBuilder() + .enableBillingProgram( + EnableBillingProgramParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.EXTERNAL_PAYMENTS) .setDeveloperProvidedBillingListener(developerBillingListener) .build() ) @@ -1087,10 +1104,12 @@ val billingClient = BillingClient.newBuilder(context) ### DeveloperProvidedBillingListener ```kotlin -val developerBillingListener = DeveloperProvidedBillingListener { - userInitiatedBillingDetails -> - // User chose the developer-provided billing flow. - // Launch your external payment UI here. +val developerBillingListener = DeveloperProvidedBillingListener { details -> + // All nullable fields depend on the selected program and flow. + val token: String? = details.externalTransactionToken + val linkUri: String? = details.linkUri + val originalTransactionId: String? = details.originalExternalTransactionId + val products: List = details.products } ``` @@ -1099,7 +1118,15 @@ val developerBillingListener = DeveloperProvidedBillingListener { ```kotlin val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productDetailsParams)) - .setBillingOption(BillingOption.EXTERNAL_PAYMENTS) // 8.3+ + .enableDeveloperBillingOption( + DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.EXTERNAL_PAYMENTS) + .setLinkUri(Uri.parse("https://example.com/checkout")) + .setLaunchMode( + DeveloperBillingOptionParams.LaunchMode.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP + ) + .build() + ) .build() billingClient.launchBillingFlow(activity, params) @@ -1109,22 +1136,163 @@ billingClient.launchBillingFlow(activity, params) | Type | Purpose | |------|---------| -| `DeveloperBillingOptionParams` | Configures developer-billing support on `BillingClient` | +| `DeveloperBillingOptionParams` | Configures developer billing on `BillingFlowParams` | | `DeveloperProvidedBillingListener` | Callback when user picks developer-provided billing | -| `DeveloperProvidedBillingDetails` | Billing details to report back for reconciliation | -| `BillingOption.EXTERNAL_PAYMENTS` | Purchase-flow flag requesting external payments | +| `DeveloperProvidedBillingDetails` | Nullable token/link/original-ID fields plus selected products | +| `BillingClient.BillingProgram.EXTERNAL_PAYMENTS` | External Payments program constant | + +> **OpenIAP Note**: Exposed through `enableBillingProgramAndroid`, +> `developerBillingOption`, and the developer-provided billing listener. +> Enrolment with Google Play's External Payments program is required; +> availability is currently restricted to Japan. Horizon and Amazon do not +> implement this Google Play program. + +## Billing Choice (9.1+) + +Billing Library 9.1 adds APIs for markets and programs where either Google Play +or the app renders a billing choice screen. + +### Integration Scenarios + +| Scenario | Choice renderer | Developer payment | BillingClient setup | Required flow | +|----------|-----------------|-------------------|---------------------|---------------| +| 1A | Google | In app | `EnableBillingProgramParams` with `DeveloperProvidedBillingListener` | Pass a minimal `DeveloperBillingOptionParams`; Play returns the token through the listener | +| 1B | Developer | In app | `EnableBillingProgramParams` without the listener | Fetch choice info, create an `IN_APP` token, show the information dialog, then render the choice UI | +| 2A | Google | External link | `EnableBillingProgramParams` with `DeveloperProvidedBillingListener` | Create an `EXTERNAL_LINK` token and pass it with the URI through `DeveloperBillingOptionParams` | +| 2B | Developer | External link | `EnableBillingProgramParams` without the listener | Fetch choice info, create an `EXTERNAL_LINK` token, render the choice UI, then pass the token to `launchExternalLink` | + +The setup must match `choiceScreenType` from Play Console. Registering the +listener in a developer-rendered integration is not equivalent to omitting it. + +| API / Type | Purpose | +|------------|---------| +| `BillingClient.getBillingChoiceInfoAsync()` | Fetches billing choices available to the current user | +| `BillingChoiceInfo` | Contains choice-screen data, including image URLs and loyalty details | +| `GetBillingChoiceInfoParams` | Configures the billing-choice info request | +| `BillingClient.showBillingProgramInformationDialog()` | Shows an information dialog for a billing program | +| `BillingProgramInformationDialogParams` | Configures the information dialog | +| `LaunchExternalLinkParams.setExternalTransactionToken()` | Supplies the pre-generated token for a developer-rendered external-link flow | +| `BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails` | Returns choice-screen type and external-link availability | +| `DeveloperBillingOptionParams` | Selects in-app or external-link developer billing during purchase | +| `BillingProgramReportingDetailsParams.DeveloperBillingType` | Distinguishes `IN_APP` and `EXTERNAL_LINK` reporting | + +### Developer Billing Purchase Options + +Only `billingProgram` is required for an in-app Billing Choice flow: + +```kotlin +val inAppChoice = DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .build() +``` + +For a Google-rendered external-link flow, also set the URI, launch mode, and the +pre-generated `EXTERNAL_LINK` transaction token: + +```kotlin +val externalLinkChoice = DeveloperBillingOptionParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setLinkUri(Uri.parse("https://example.com/checkout")) + .setLaunchMode(DeveloperBillingOptionParams.LaunchMode.CALLER_WILL_LAUNCH_LINK) + .setExternalTransactionToken(preGeneratedToken) + .build() +``` + +### Developer-Rendered Choice Information + +```kotlin +val params = GetBillingChoiceInfoParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setPlayBillingChoiceImageLayout( + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_FOUR_BY_ONE + ) + .setUserLocale(Locale.forLanguageTag("en-US")) + .build() + +billingClient.getBillingChoiceInfoAsync(params) { result, info -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + val imageUrl = info.playBillingChoiceImageUrl + val loyaltyText = info.playBillingLoyaltyInfo + } +} +``` + +Supported image layouts are `RECTANGULAR_FOUR_BY_ONE`, +`RECTANGULAR_THREE_BY_ONE`, and `RECTANGULAR_TWO_BY_TWO`. + +### Availability Details -> **OpenIAP Note**: Exposed through the Android-specific `AlternativeBilling*` surface in OpenIAP. Enrolment with Google Play's External Payments program is required; availability is currently restricted to Japan. The Horizon flavor does NOT implement this. +For `BILLING_CHOICE`, `BillingProgramAvailabilityDetails` can include: + +| Field | Meaning | +|-------|---------| +| `choiceScreenType` | `UNSPECIFIED`, `DEVELOPER_RENDERED`, or `GOOGLE_RENDERED` | +| `isExternalLinkAvailable` | Whether the user is eligible for an external-link option | + +### Information Dialog + +For developer-rendered in-app choice (scenario 1B), call +`showBillingProgramInformationDialog()` before showing the app's choice UI. It +is a UI-thread API and returns through its listener; it does not return a +synchronous `BillingResult`: + +```kotlin +val params = BillingProgramInformationDialogParams.newBuilder() + .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) + .setExternalTransactionToken(externalTransactionToken) + .build() + +billingClient.showBillingProgramInformationDialog(activity, params) { result -> + // Continue according to result.responseCode. +} +``` + +### Developer-Billed Subscription Replacement + +Use the original external transaction ID instead of an old Play purchase token +when replacing a subscription bought through developer billing: + +```kotlin +val updateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOriginalExternalTransactionId(originalExternalTransactionId) + .build() +``` + +> **OpenIAP Note**: OpenIAP exposes these through `BILLING_CHOICE`, +> `getBillingChoiceInfoAndroid`, `showBillingProgramInformationDialogAndroid`, +> `launchExternalLinkAndroid`, `developerBillingOption`, +> `originalExternalTransactionId`, and the expanded developer-provided billing +> callback. Set `InitConnectionConfig.billingChoiceScreenTypeAndroid` to +> `GOOGLE_RENDERED` (default) or `DEVELOPER_RENDERED` so OpenIAP includes or +> omits the listener correctly. Play-only APIs return unsupported/default +> behavior on Horizon and Amazon. + +## In-App Billing Messages (4.1+) + +`showInAppMessages()` must run on the UI thread. It returns a synchronous +`BillingResult` for submission errors and reports the user interaction through +`InAppMessageResponseListener`. + +## PBL 9 Migration Guardrails + +- Replace removed APIs: `SkuDetails`, `SkuDetailsParams`, `SkuDetailsResponseListener`, `BillingClient.SkuType`, `querySkuDetailsAsync()`, no-argument `enablePendingPurchases()`, and string `queryPurchasesAsync()`. +- Use `ProductDetails`, `QueryProductDetailsParams`, `BillingClient.ProductType`, parameterized `enablePendingPurchases(PendingPurchasesParams)`, and `queryPurchasesAsync(QueryPurchasesParams, ...)`. +- Handle `DeveloperProvidedBillingDetails.getExternalTransactionToken()`, + `getLinkUri()`, and `getOriginalExternalTransactionId()` as nullable. +- Preserve every `DeveloperProvidedBillingDetails.Product` (`id`, `type`, and + nullable `offerToken`) from the callback. +- Keep Horizon shared code on the Billing 7.0-compatible API subset; put PBL 8/9 code in Play-only sources or behind reflection. ## Best Practices 1. **Always acknowledge purchases** within 3 days or they will be refunded 2. **Verify purchases server-side** using Google Play Developer API 3. **Handle pending purchases** for payment methods that require additional steps -4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+) +4. **Auto-reconnect is enabled by default** in OpenIAP when available (8.0+) 5. **Check product status codes** (8.0+) to understand why products weren't fetched 6. **Check isSuspended** (8.1+) before granting entitlements -7. **Cache product details** to avoid repeated queries +7. **Distinguish in-app and external-link Billing Choice** when configuring and reporting developer billing +8. **Cache product details** to avoid repeated queries --- @@ -1145,18 +1313,32 @@ Meta Horizon provides IAP functionality for Quest VR applications. There are two | Library | Version | Compatible With | |---------|---------|-----------------| -| horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API | -| Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A | +| horizon-billing-compatibility | **2.0.0** (latest) | Google Play Billing **7.0** API | +| Google Play Billing (upstream latest) | **9.1.0** | N/A | +| Google Play Billing (OpenIAP Play flavor) | **9.1.0** | N/A | | react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ | | expo-iap | latest | Billing 7.0+, Kotlin 2.0+ | -**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. +**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x or 9.x. When writing shared code for both Play and Horizon flavors: -- Use only APIs that exist in **both** Billing 7.0 and 8.x -- Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended` +- Use only APIs that exist in **both** Billing 7.0 and the Play-flavor Billing version +- Horizon SDK does NOT support Billing 8.x/9.x features like auto-reconnect, product status codes, `includeSuspended`, or Billing Choice - OpenIAP handles this automatically with flavor-specific implementations +### Latest Horizon Billing Release + +Meta released Horizon Billing Compatibility Library **2.0.0** on 2026-01-06. +The release notes call out a fix for querying subscription purchases with a +single billing plan and dependencies on Horizon Platform SDK Kotlin +`iap-kotlin` 0.2.0 and `core-kotlin` 0.2.0. + +OpenIAP uses `horizon-billing-compatibility` 2.0.0 and its transitive Horizon +Platform Kotlin SDK modules. The Horizon flavor is compiled and tested +separately, including subscription-purchase restore coverage. Play Billing +9.1.0 features remain Play-flavor-only because Horizon compatibility still +targets the Billing 7.0 API surface. + ### APIs Available in Both (Safe to use in shared code) - `BillingClient.Builder`, `BillingClient.newBuilder()` @@ -1178,6 +1360,12 @@ When writing shared code for both Play and Horizon flavors: - Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+) - External Payments / Developer Billing Options (8.3+) +### APIs Only in Billing 9.x (DO NOT use in shared code) + +- Billing Choice information APIs (`getBillingChoiceInfoAsync`, `BillingChoiceInfo`, `ChoiceScreenType`) +- Billing-program information dialog APIs (`showBillingProgramInformationDialog`) +- PBL 9 migration assumptions around APIs removed from the Billing 7.0 surface + ## Billing Compatibility SDK For apps already using Google Play Billing Library, the Horizon Billing Compatibility SDK provides a minimal migration path. @@ -1211,6 +1399,10 @@ import com.meta.horizon.billingclient.api.* ### Important Notes +- Horizon Billing Compatibility 2.x reads the app id from Android manifest + meta-data key `com.meta.horizon.platform.HORIZON_APP_ID`. The older + `com.meta.horizon.platform.ovr.OCULUS_APP_ID` key is deprecated; OpenIAP also + accepts it and other historical keys only for migration compatibility. - Keep SKUs on Meta Horizon Developer Center same as Google Play Console product IDs - Only call `consumeAsync()` on consumable items - `acknowledgePurchase()` is no-op - no acknowledgement requirements @@ -1822,7 +2014,7 @@ export default withIAPContext(Store); This document provides external API reference for Apple's StoreKit 2 framework. -## iOS 18+ Features +## iOS 18+ / 26+ Features | Feature | iOS Version | Description | |---------|-------------|-------------| @@ -1841,6 +2033,11 @@ This document provides external API reference for Apple's StoreKit 2 framework. | JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format | | `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option | | `SubscriptionStatus` by Transaction ID | WWDC 2025 | `status(for: transactionID:)` | +| Monthly subscriptions with a 12-month commitment | iOS 26.4 / 26.5 SDK | Monthly billing option for annual auto-renewable subscriptions | +| Group purchases and volume purchasing | WWDC 2026 | Multi-seat auto-renewable subscriptions through StoreKit 2 and Apple Business / School Manager | +| Retention Messaging | WWDC 2026 | Cancellation-flow messaging and offers, including real-time server decisioning | +| Retention offer type | WWDC 2026 | Signed transaction / renewal info can report offer type `5` for retention offers | +| Offer codes for all IAP types | 2026 | Offer codes expand beyond auto-renewable subscriptions; IAP promo-code creation ends March 26, 2026 | ### WWDC 2025 Updates @@ -1849,6 +2046,14 @@ This document provides external API reference for Apple's StoreKit 2 framework. - **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option. - Both new purchase options are back-deployed to iOS 15. +### WWDC 2026 Updates + +- **Monthly subscriptions with a 12-month commitment**: iOS 26.5 SDK adds a monthly billing plan for one-year auto-renewable subscriptions. Customers can subscribe on iOS, iPadOS, macOS, tvOS, and visionOS 26.4+. +- **Group purchases and volume purchasing**: Auto-renewable subscriptions using StoreKit 2 can be sold to groups and organizations. In-app group purchases pass a requested seat count into the StoreKit purchase flow; Apple Business Manager and Apple School Manager handle volume purchasing. +- **Volume pricing**: App Store Connect can configure up to five seat-count price bands for larger subscription purchases. +- **Retention Messaging**: App Store Connect can show cancellation-flow retention messages and offers. Real-time Retention Messaging adds a server-to-server decision point and supports a switch-plan view for monthly subscriptions with a 12-month commitment. +- **Offer-code expansion**: Offer codes now support consumables, non-consumables, non-renewing subscriptions, and broader auto-renewable subscription scenarios. Starting March 26, 2026, App Store Connect no longer creates new promo codes for In-App Purchases. + ## appAccountToken A UUID that associates a purchase with a user account in your system. This property allows you to correlate App Store transactions with users in your backend. @@ -2130,6 +2335,43 @@ if let advancedInfo = product.advancedCommerceInfo { } ``` +## Monthly Subscriptions With 12-Month Commitment (iOS 26.4+) + +This billing plan lets customers pay monthly while committing to an annual +auto-renewable subscription. Apps need to compile with the iOS 26.5 SDK to +merchandise the plan, and customers can purchase on Apple platforms running +26.4 or later. + +```swift +let result = try await product.purchase(options: [ + .billingPlanType(.monthly) +]) +``` + +> **OpenIAP Note**: The schema represents this with +> `SubscriptionBillingPlanTypeIOS` and `RequestSubscriptionIOSProps.billingPlanType`. + +## Group Purchases and Volume Purchasing (WWDC 2026) + +StoreKit 2 auto-renewable subscriptions can be sold to multiple seats for +groups or organizations. Volume purchasing is handled by Apple Business Manager +and Apple School Manager. For in-app group purchases, the app starts a StoreKit +purchase with the requested seat count, then Apple can manage invitation links +and seat assignment unless the app integrates custom group management. + +> **OpenIAP gap**: No public OpenIAP request field exists yet for group-purchase +> seat count or StoreKit group-management identifiers. + +## Retention Messaging (WWDC 2026) + +Retention Messaging lets App Store Connect present messages and optional offers +when a subscriber is about to cancel. Real-time Retention Messaging can call a +server endpoint so the developer can choose the message, offer, or switch-plan +view at cancellation time. + +Signed transaction and renewal information can include a retention offer as +offer type `5`. + ## StoreKit Message API (iOS 18+) Listen for App Store–generated messages (billing issues, win-back offers, price increases, generic). diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index f4e5f4542..4785818a8 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-07-04T10:52:01.940Z +> Generated: 2026-07-10T22:14:59.416Z ## Installation @@ -19,7 +19,7 @@ npm install react-native-iap ### Native ```swift // Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.4") +.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.5-rc.1") ``` ```kotlin @@ -36,20 +36,20 @@ flutter pub add flutter_inapp_purchase ```gdscript # Godot -# Install godot-iap 2.4.0-rc.1 to addons/godot-iap and enable the plugin +# Install godot-iap 2.4.0-rc.3 to addons/godot-iap and enable the plugin ``` ```kotlin // Kotlin Multiplatform -implementation("io.github.hyochan:kmp-iap:2.4.0-rc.1") +implementation("io.github.hyochan:kmp-iap:2.4.0-rc.3") ``` ```xml - + ``` -Current NuGet package version: 1.2.0-rc.1 +Current NuGet package version: 1.2.1-rc.2 ## Framework Libraries diff --git a/packages/docs/src/components/CodeBlock.tsx b/packages/docs/src/components/CodeBlock.tsx index 43ce47c87..b8544f56f 100644 --- a/packages/docs/src/components/CodeBlock.tsx +++ b/packages/docs/src/components/CodeBlock.tsx @@ -30,9 +30,15 @@ const TYPE_LINKS: Record = { ActiveSubscription: '/docs/types/active-subscription', AlternativeBillingModeAndroid: '/docs/types/alternative-billing-types', AlternativeBillingTypes: '/docs/types/alternative-billing-types', + BillingChoiceImageLayoutAndroid: '/docs/types/billing-programs', + BillingChoiceInfoAndroid: '/docs/types/billing-programs', + BillingChoiceScreenTypeAndroid: '/docs/types/billing-programs', BillingProgramAndroid: '/docs/types/billing-programs', BillingProgramAvailabilityResultAndroid: '/docs/types/billing-programs', + BillingProgramInformationDialogParamsAndroid: '/docs/types/billing-programs', BillingProgramReportingDetailsAndroid: '/docs/types/billing-programs', + BillingResultAndroid: '/docs/types/billing-programs', + DeveloperBillingTypeAndroid: '/docs/types/billing-programs', DeepLinkOptions: '/docs/types#common', DiscountOffer: '/docs/types/discount-offer', ExternalPurchaseLinkResultIOS: '/docs/types/external-purchase-link', @@ -46,6 +52,11 @@ const TYPE_LINKS: Record = { FetchProductsResult: '/docs/types/product-request', InitConnectionConfig: '/docs/types/alternative-billing-types#init-connection-config', + GetBillingChoiceInfoParamsAndroid: '/docs/types/billing-programs', + InAppMessageCategoryAndroid: '/docs/types/billing-programs', + InAppMessageParamsAndroid: '/docs/types/billing-programs', + InAppMessageResponseCodeAndroid: '/docs/types/billing-programs', + InAppMessageResultAndroid: '/docs/types/billing-programs', LaunchExternalLinkParamsAndroid: '/docs/types/billing-programs', Product: '/docs/types/product', ProductAndroid: '/docs/types/product', diff --git a/packages/docs/src/generated/version-metadata.json b/packages/docs/src/generated/version-metadata.json index 520c82c04..378c160d9 100644 --- a/packages/docs/src/generated/version-metadata.json +++ b/packages/docs/src/generated/version-metadata.json @@ -9,7 +9,7 @@ "mauiPackageVersion": "1.2.1-rc.2", "googleCompileSdk": "35", "googleMinSdk": "23", - "googlePlayBillingVersion": "8.3.0", + "googlePlayBillingVersion": "9.1.0", "kmpCompileSdk": "35", "kmpMinSdk": "24", "kmpTargetSdk": "35" diff --git a/packages/docs/src/lib/images.ts b/packages/docs/src/lib/images.ts index d662fbc76..2ad0c3c96 100644 --- a/packages/docs/src/lib/images.ts +++ b/packages/docs/src/lib/images.ts @@ -87,7 +87,7 @@ export const LIBRARIES: LibraryInfo[] = [ languagesDescription: 'Bare React Native implementation of OpenIAP specification (Nitro Modules)', setupDescription: - 'Bare React Native CLI projects (RN 0.79+). Built on Nitro Modules with the `useIAP` hook, error normalization, and full StoreKit 2 / Play Billing 8 coverage.', + 'Bare React Native CLI projects (RN 0.79+). Built on Nitro Modules with the `useIAP` hook, error normalization, and full StoreKit 2 / Play Billing 9.1 coverage.', installCommand: REACT_NATIVE_PACKAGE.installCommand, documentationUrl: 'https://openiap.dev/docs/setup/react-native', url: 'https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap', diff --git a/packages/docs/src/lib/searchData.ts b/packages/docs/src/lib/searchData.ts index 5de3132ac..5a82609a3 100644 --- a/packages/docs/src/lib/searchData.ts +++ b/packages/docs/src/lib/searchData.ts @@ -457,6 +457,16 @@ export const apiData: ApiItem[] = [ returns: 'BillingProgramAvailabilityResultAndroid!', path: '/docs/apis/android/is-billing-program-available-android', }, + { + id: 'get-billing-choice-info-android', + title: 'getBillingChoiceInfoAndroid', + category: 'Android Specific', + description: + 'Fetch Play Billing Choice display assets for developer-rendered choice screens', + parameters: 'params: GetBillingChoiceInfoParamsAndroid!', + returns: 'BillingChoiceInfoAndroid!', + path: '/docs/apis/android/get-billing-choice-info-android', + }, { id: 'launch-external-link-android', title: 'launchExternalLinkAndroid', @@ -477,6 +487,26 @@ export const apiData: ApiItem[] = [ returns: 'BillingProgramReportingDetailsAndroid!', path: '/docs/apis/android/create-billing-program-reporting-details-android', }, + { + id: 'show-billing-program-information-dialog-android', + title: 'showBillingProgramInformationDialogAndroid', + category: 'Android Specific', + description: + 'Show the mandatory Play information dialog before a developer-rendered in-app Billing Choice screen', + parameters: 'params: BillingProgramInformationDialogParamsAndroid!', + returns: 'BillingResultAndroid!', + path: '/docs/apis/android/show-billing-program-information-dialog-android', + }, + { + id: 'show-in-app-messages-android', + title: 'showInAppMessagesAndroid', + category: 'Android Specific', + description: + 'Show Google Play billing in-app messages and return subscription status updates when applicable', + parameters: 'params: InAppMessageParamsAndroid', + returns: 'InAppMessageResultAndroid!', + path: '/docs/apis/android/show-in-app-messages-android', + }, // Debugging & Logging (moved to Features) { @@ -822,7 +852,7 @@ export const apiData: ApiItem[] = [ title: 'Billing Programs', category: 'Types', description: - 'Android Billing Programs API (Play Billing 8.2.0+): BillingProgramAndroid, ExternalLink launch modes, Developer Provided Billing parameters', + 'Android Billing Programs API (Play Billing 8.2.0+ and Billing Choice 9.1.0+): BillingProgramAndroid, ExternalLink launch modes, Billing Choice, and Developer Provided Billing parameters', path: '/docs/types/billing-programs', }, diff --git a/packages/docs/src/pages/docs/android-setup.tsx b/packages/docs/src/pages/docs/android-setup.tsx index 5af1a5af0..592ac15fc 100644 --- a/packages/docs/src/pages/docs/android-setup.tsx +++ b/packages/docs/src/pages/docs/android-setup.tsx @@ -1,14 +1,14 @@ import SEO from '../../components/SEO'; -import { OPENIAP_VERSIONS } from '../../lib/versioning'; +import { GOOGLE_PLAY_BILLING, OPENIAP_VERSIONS } from '../../lib/versioning'; function AndroidSetup() { return (

Android Setup Guide

@@ -212,9 +212,10 @@ function AndroidSetup() {

The OpenIAP Android core library (openiap-google) requires minSdk 23 (Android 6.0) with Google Play - Billing 8.x. Each framework library may require a higher minimum — - check the framework-specific setup page for - the exact value. + Billing {GOOGLE_PLAY_BILLING.version}. Each framework library may + require a higher minimum — check the{' '} + framework-specific setup page for the exact + value.

@@ -437,6 +438,70 @@ dependencies { fraud and ensure purchase validity.

+

+ Billing Choice and Billing Programs + + # + +

+

+ OpenIAP's Android core uses Google Play Billing{' '} + {GOOGLE_PLAY_BILLING.version}. Billing Choice is exposed through the + Billing Programs API with{' '} + BillingProgramAndroid.BillingChoice in native Kotlin and{' '} + 'billing-choice' in TypeScript. +

+ +

Subscription Management diff --git a/packages/docs/src/pages/docs/apis/android/create-billing-program-reporting-details-android.tsx b/packages/docs/src/pages/docs/apis/android/create-billing-program-reporting-details-android.tsx index b7e768001..de8384fb9 100644 --- a/packages/docs/src/pages/docs/apis/android/create-billing-program-reporting-details-android.tsx +++ b/packages/docs/src/pages/docs/apis/android/create-billing-program-reporting-details-android.tsx @@ -27,10 +27,13 @@ function CreateBillingProgramReportingDetailsAndroid() {

Wraps{' '} - BillingClient.createBillingProgramReportingDetailsAsync(BillingProgram) + BillingClient.createBillingProgramReportingDetailsAsync(...) {' '} — returns the external transaction token to report a Developer-Provided - Billing transaction. Play Billing 8.3.0+. See the{' '} + Billing transaction. Play Billing 8.3.0+; the optional developer billing + type parameter is available in OpenIAP Spec 2.1.0 and{' '} + openiap-google 2.3.0 for Billing Choice, which requires + Play Billing 9.1.0+. See the{' '} ), kmp: ( {`suspend fun createBillingProgramReportingDetailsAndroid( - program: BillingProgramAndroid + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? = null ): BillingProgramReportingDetailsAndroid`} ), typescript: ( {`createBillingProgramReportingDetailsAndroid( - program: BillingProgramAndroid + program: BillingProgramAndroid, + developerBillingType?: DeveloperBillingTypeAndroid | null ): Promise`} ), dart: ( {`Future createBillingProgramReportingDetailsAndroid( BillingProgramAndroid program, + {DeveloperBillingTypeAndroid? developerBillingType} );`} ), csharp: ( {`// Returns BillingProgramReportingDetailsAndroid with externalTransactionToken // Token must be reported to Google Play backend within 24 hours // Throws OpenIapError.NotPrepared if billing client not ready -Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program);`} +Task CreateBillingProgramReportingDetailsAndroidAsync( + BillingProgramAndroid program, + DeveloperBillingTypeAndroid? developerBillingType = null +);`} ), gdscript: ( {`func create_billing_program_reporting_details_android( - program: int + program: int, + developer_billing_type = null ) -> BillingProgramReportingDetailsAndroid`} ), }} @@ -97,6 +108,19 @@ Task CreateBillingProgramReportingDetails {' '} — Billing program identifier. +

  • + developerBillingType{' '} + + (optional,{' '} + + DeveloperBillingTypeAndroid + + ) + {' '} + — Billing Choice reporting destination. Use 'in-app' for + native in-app developer billing, or 'external-link' when + the developer billing option uses a link. +
  • @@ -116,20 +140,6 @@ Task CreateBillingProgramReportingDetails {' '} — Token to send to Google's reporting API. Required for compliance. -
  • - responseCode{' '} - - (number?) - {' '} - — Raw Play Billing response code (when Play returned one). -
  • -
  • - debugMessage{' '} - - (string?) - {' '} - — Optional debug message from Play. -
  • Example

    @@ -152,7 +162,8 @@ import { createBillingProgramReportingDetailsAndroid } from 'expo-iap'; if (Platform.OS === 'android') { const details = await createBillingProgramReportingDetailsAndroid( - 'external-offer', + 'billing-choice', + 'in-app', ); }`} ), diff --git a/packages/docs/src/pages/docs/apis/android/enable-billing-program-android.tsx b/packages/docs/src/pages/docs/apis/android/enable-billing-program-android.tsx index 5a6085aff..7badfeafd 100644 --- a/packages/docs/src/pages/docs/apis/android/enable-billing-program-android.tsx +++ b/packages/docs/src/pages/docs/apis/android/enable-billing-program-android.tsx @@ -21,8 +21,9 @@ function EnableBillingProgramAndroid() { enableBillingProgramAndroid

    - Enables a billing program for Android (Billing Library 8.2.0+). Pass it - as the{' '} + Enables a billing program for Android. Billing Choice support starts in + OpenIAP Spec 2.1.0 and openiap-google 2.3.0 and requires + Play Billing 9.1.0+. Pass it as the{' '} enableBillingProgramAndroid {' '} @@ -37,7 +38,8 @@ function EnableBillingProgramAndroid() { Sets enableBillingProgramAndroid on{' '} InitConnectionConfig; under the hood it configures{' '} BillingClient.Builder.enableBillingPrograms(...) (Play - Billing 8.2.0+). See the{' '} + Billing 8.2.0+). Use 'billing-choice' for Play Billing + Billing Choice. See the{' '} + +

    + Android{' '} + getBillingChoiceInfoAndroid +

    +

    + Fetches Google Play Billing Choice display information for + developer-rendered choice screens. Available in OpenIAP Spec 2.1.0 and{' '} + openiap-google 2.3.0; requires Play Billing 9.1.0+. +

    +

    + Wraps BillingClient.getBillingChoiceInfoAsync(...). Call + this when{' '} + + isBillingProgramAvailableAndroid('billing-choice') + {' '} + returns choiceScreenType: 'developer-rendered'. +

    + +

    Signature

    + + {{ + kotlin: ( + {`suspend fun getBillingChoiceInfo( + params: GetBillingChoiceInfoParamsAndroid +): BillingChoiceInfoAndroid`} + ), + kmp: ( + {`suspend fun getBillingChoiceInfoAndroid( + params: GetBillingChoiceInfoParamsAndroid +): BillingChoiceInfoAndroid`} + ), + typescript: ( + {`getBillingChoiceInfoAndroid( + params: GetBillingChoiceInfoParamsAndroid +): Promise`} + ), + dart: ( + {`Future getBillingChoiceInfoAndroid( + GetBillingChoiceInfoParamsAndroid params, +);`} + ), + csharp: ( + {`Task GetBillingChoiceInfoAndroidAsync( + GetBillingChoiceInfoParamsAndroid @params +);`} + ), + gdscript: ( + {`func get_billing_choice_info_android( + params: GetBillingChoiceInfoParamsAndroid +) -> BillingChoiceInfoAndroid`} + ), + }} + + + + Parameters + +

    + Pass one{' '} + + GetBillingChoiceInfoParamsAndroid + + : +

    +
      +
    • + billingProgram{' '} + + (optional,{' '} + + BillingProgramAndroid + + ) + {' '} + - defaults to 'billing-choice'. +
    • +
    • + playBillingChoiceImageLayout{' '} + + (optional,{' '} + + BillingChoiceImageLayoutAndroid + + ) + {' '} + - defaults to 'rectangular-four-by-one'. +
    • +
    • + userLocale{' '} + + (optional, string | null) + {' '} + - BCP 47 locale tag. Omit it to use the user's default locale. +
    • +
    + + + Returns + +

    + + Promise<BillingChoiceInfoAndroid> + {' '} + with: +

    +
      +
    • + playBillingChoiceImageUrl{' '} + + (string) + {' '} + - Google Play image URL for the requested layout. +
    • +
    • + playBillingLoyaltyInfo{' '} + + (string | null) + {' '} + - Optional Play loyalty text for the user. +
    • +
    + +

    Example

    + + {{ + kotlin: ( + {`val info = openIapStore.getBillingChoiceInfo( + GetBillingChoiceInfoParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.RectangularFourByOne + ) +)`} + ), + kmp: ( + {`val info = kmpIAP.getBillingChoiceInfoAndroid( + GetBillingChoiceInfoParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.RectangularFourByOne + ) +)`} + ), + typescript: ( + {`import { getBillingChoiceInfoAndroid } from 'expo-iap'; + +if (Platform.OS === 'android') { + const info = await getBillingChoiceInfoAndroid({ + billingProgram: 'billing-choice', + playBillingChoiceImageLayout: 'rectangular-four-by-one', + }); +}`} + ), + dart: ( + {`if (Platform.isAndroid) { + final info = await FlutterInappPurchase.instance.getBillingChoiceInfoAndroid( + GetBillingChoiceInfoParamsAndroid( + billingProgram: BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout: + BillingChoiceImageLayoutAndroid.RectangularFourByOne, + ), + ); +}`} + ), + csharp: ( + {`var info = await ((QueryResolver)OpenIapClient.Instance).GetBillingChoiceInfoAndroidAsync( + new GetBillingChoiceInfoParamsAndroid + { + BillingProgram = BillingProgramAndroid.BillingChoice, + PlayBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + });`} + ), + gdscript: ( + {`if iap.get_platform() == "Android": + var params = GetBillingChoiceInfoParamsAndroid.new() + params.billing_program = BillingProgramAndroid.BILLING_CHOICE + params.play_billing_choice_image_layout = BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE + var info = await iap.get_billing_choice_info_android(params)`} + ), + }} + +
    + ); +} + +export default GetBillingChoiceInfoAndroid; diff --git a/packages/docs/src/pages/docs/apis/android/is-billing-program-available-android.tsx b/packages/docs/src/pages/docs/apis/android/is-billing-program-available-android.tsx index c272a2180..818fc23b6 100644 --- a/packages/docs/src/pages/docs/apis/android/is-billing-program-available-android.tsx +++ b/packages/docs/src/pages/docs/apis/android/is-billing-program-available-android.tsx @@ -30,7 +30,9 @@ function IsBillingProgramAvailableAndroid() { BillingClient.isBillingProgramAvailableAsync(BillingProgram) {' '} — replaces isExternalOfferAvailableAsync. Play Billing - 8.2.0+. See the{' '} + 8.2.0+; Billing Choice fields are available in OpenIAP Spec 2.1.0 and{' '} + openiap-google 2.3.0 and require Play Billing 9.1.0+. See + the{' '} IsBillingProgramAvailableAndroidAs {' '} — Whether the billing program is available for this user/device. +
  • + choiceScreenType{' '} + + ( + + BillingChoiceScreenTypeAndroid + + | null) + {' '} + — Populated only for available 'billing-choice' results. +
  • +
  • + isExternalLinkAvailable{' '} + + (boolean | null) + {' '} + — Whether external-link developer billing is available for Billing + Choice. +
  • Example

    @@ -144,7 +165,11 @@ val result = kmpIAP.isBillingProgramAvailableAndroid( import { isBillingProgramAvailableAndroid } from 'expo-iap'; if (Platform.OS === 'android') { - const result = await isBillingProgramAvailableAndroid('external-offer'); + const result = await isBillingProgramAvailableAndroid('billing-choice'); + + if (result.choiceScreenType === 'developer-rendered') { + // Render your choice screen with getBillingChoiceInfoAndroid(). + } }`} ), dart: ( diff --git a/packages/docs/src/pages/docs/apis/android/launch-external-link-android.tsx b/packages/docs/src/pages/docs/apis/android/launch-external-link-android.tsx index 4d4f7d133..e47f74ef5 100644 --- a/packages/docs/src/pages/docs/apis/android/launch-external-link-android.tsx +++ b/packages/docs/src/pages/docs/apis/android/launch-external-link-android.tsx @@ -31,7 +31,7 @@ function LaunchExternalLinkAndroid() { {' '} — replaces showExternalOfferInformationDialog. Shows the Play disclosure dialog and (optionally) launches the URL. Play Billing - 8.2.0+. See the{' '} + 8.2.0+. Billing Choice external-link flows require 9.1.0+. See the{' '}
    @@ -81,6 +82,7 @@ Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @param // LaunchExternalLinkParamsAndroid: // - billingProgram: BillingProgramAndroid +// - externalTransactionToken: string? (Billing Choice external-link flow) // - launchMode: ExternalLinkLaunchModeAndroid // - linkType: ExternalLinkTypeAndroid // - linkUri: string`} @@ -113,8 +115,21 @@ Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @param ) {' '} - — Billing program the link belongs to (e.g.{' '} - EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER). + — Billing program the link belongs to ( + EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or{' '} + BILLING_CHOICE). + +
  • + externalTransactionToken{' '} + + (optional, string) + {' '} + — Pre-generated reporting token for a developer-rendered Billing + Choice external-link flow (scenario 2B). Generate it with{' '} + + createBillingProgramReportingDetailsAndroid + {' '} + using EXTERNAL_LINK, then pass the same token here.
  • launchMode{' '} @@ -143,8 +158,9 @@ Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @param Returns

    - Promise<boolean> — true once the Play - disclosure dialog finished and (optionally) the URL was opened. + Promise<boolean> — true when Play + accepts the launch request and its disclosure/link flow completes + successfully.

    Example

    @@ -154,7 +170,8 @@ Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @param {`openIapStore.launchExternalLink( activity, LaunchExternalLinkParamsAndroid( - billingProgram = BillingProgramAndroid.ExternalOffer, + billingProgram = BillingProgramAndroid.BillingChoice, + externalTransactionToken = reportingDetails.externalTransactionToken, launchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, linkType = ExternalLinkTypeAndroid.LinkToDigitalContentOffer, linkUri = "https://example.com/offer" @@ -165,7 +182,8 @@ Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @param {`// kmp-iap (Android targets only — no-op on iOS) kmpIAP.launchExternalLinkAndroid( LaunchExternalLinkParamsAndroid( - billingProgram = BillingProgramAndroid.ExternalOffer, + billingProgram = BillingProgramAndroid.BillingChoice, + externalTransactionToken = reportingDetails.externalTransactionToken, launchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, linkType = ExternalLinkTypeAndroid.LinkToDigitalContentOffer, linkUri = "https://example.com/offer" @@ -178,7 +196,8 @@ import { launchExternalLinkAndroid } from 'expo-iap'; if (Platform.OS === 'android') { await launchExternalLinkAndroid({ - billingProgram: 'external-offer', + billingProgram: 'billing-choice', + externalTransactionToken: reportingDetails.externalTransactionToken, launchMode: 'launch-in-external-browser-or-app', linkType: 'link-to-digital-content-offer', linkUri: 'https://example.com/offer', @@ -189,7 +208,8 @@ if (Platform.OS === 'android') { {`if (Platform.isAndroid) { await FlutterInappPurchase.instance.launchExternalLinkAndroid( LaunchExternalLinkParamsAndroid( - billingProgram: BillingProgramAndroid.externalOffer, + billingProgram: BillingProgramAndroid.BillingChoice, + externalTransactionToken: reportingDetails.externalTransactionToken, launchMode: ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, linkType: ExternalLinkTypeAndroid.LinkToDigitalContentOffer, linkUri: 'https://example.com/offer', @@ -204,7 +224,8 @@ using OpenIap.Maui; await ((MutationResolver)OpenIapClient.Instance).LaunchExternalLinkAndroidAsync( new LaunchExternalLinkParamsAndroid { - BillingProgram = BillingProgramAndroid.ExternalOffer, + BillingProgram = BillingProgramAndroid.BillingChoice, + ExternalTransactionToken = reportingDetails.ExternalTransactionToken, LaunchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, LinkType = ExternalLinkTypeAndroid.LinkToDigitalContentOffer, LinkUri = "https://example.com/offer", @@ -213,7 +234,8 @@ await ((MutationResolver)OpenIapClient.Instance).LaunchExternalLinkAndroidAsync( gdscript: ( {`if iap.get_platform() == "Android": var params = LaunchExternalLinkParamsAndroid.new() - params.billing_program = BillingProgramAndroid.EXTERNAL_OFFER + params.billing_program = BillingProgramAndroid.BILLING_CHOICE + params.external_transaction_token = reporting_details.external_transaction_token params.launch_mode = ExternalLinkLaunchModeAndroid.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP params.link_type = ExternalLinkTypeAndroid.LINK_TO_DIGITAL_CONTENT_OFFER params.link_uri = "https://example.com/offer" diff --git a/packages/docs/src/pages/docs/apis/android/show-billing-program-information-dialog-android.tsx b/packages/docs/src/pages/docs/apis/android/show-billing-program-information-dialog-android.tsx new file mode 100644 index 000000000..4ab661519 --- /dev/null +++ b/packages/docs/src/pages/docs/apis/android/show-billing-program-information-dialog-android.tsx @@ -0,0 +1,182 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../../components/AnchorLink'; +import CodeBlock from '../../../../components/CodeBlock'; +import LanguageTabs from '../../../../components/LanguageTabs'; +import SEO from '../../../../components/SEO'; +import { useScrollToHash } from '../../../../hooks/useScrollToHash'; + +function ShowBillingProgramInformationDialogAndroid() { + useScrollToHash(); + + return ( +
    + +

    + Android{' '} + showBillingProgramInformationDialogAndroid +

    +

    + Shows Google's billing program information dialog for Billing Choice. + Available in OpenIAP Spec 2.1.0 and openiap-google 2.3.0; + requires Play Billing 9.1.0+. +

    +

    + Wraps{' '} + BillingClient.showBillingProgramInformationDialog(...). For + a developer-rendered, in-app flow, call this after creating an{' '} + IN_APP reporting token and before showing your choice + screen. Google-rendered and external-link flows do not use this step. +

    + +

    Signature

    + + {{ + kotlin: ( + {`suspend fun showBillingProgramInformationDialog( + activity: Activity, + params: BillingProgramInformationDialogParamsAndroid +): BillingResultAndroid`} + ), + kmp: ( + {`suspend fun showBillingProgramInformationDialogAndroid( + params: BillingProgramInformationDialogParamsAndroid +): BillingResultAndroid`} + ), + typescript: ( + {`showBillingProgramInformationDialogAndroid( + params: BillingProgramInformationDialogParamsAndroid +): Promise`} + ), + dart: ( + {`Future showBillingProgramInformationDialogAndroid( + BillingProgramInformationDialogParamsAndroid params, +);`} + ), + csharp: ( + {`Task ShowBillingProgramInformationDialogAndroidAsync( + BillingProgramInformationDialogParamsAndroid @params +);`} + ), + gdscript: ( + {`func show_billing_program_information_dialog_android( + params: BillingProgramInformationDialogParamsAndroid +) -> BillingResultAndroid`} + ), + }} + + + + Parameters + +

    + Pass one{' '} + + BillingProgramInformationDialogParamsAndroid + + : +

    +
      +
    • + billingProgram{' '} + + (optional,{' '} + + BillingProgramAndroid + + ) + {' '} + - defaults to 'billing-choice'. +
    • +
    • + externalTransactionToken{' '} + + (required, string) + {' '} + - token from{' '} + + createBillingProgramReportingDetailsAndroid() + + . +
    • +
    + + + Returns + +

    + + Promise<BillingResultAndroid> + {' '} + with responseCode, optional debugMessage, and + optional subResponseCode. +

    + +

    Example

    + + {{ + kotlin: ( + {`val result = openIapStore.showBillingProgramInformationDialog( + activity, + BillingProgramInformationDialogParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice, + externalTransactionToken = reportingDetails.externalTransactionToken + ) +)`} + ), + kmp: ( + {`val result = kmpIAP.showBillingProgramInformationDialogAndroid( + BillingProgramInformationDialogParamsAndroid( + billingProgram = BillingProgramAndroid.BillingChoice, + externalTransactionToken = reportingDetails.externalTransactionToken + ) +)`} + ), + typescript: ( + {`import { showBillingProgramInformationDialogAndroid } from 'expo-iap'; + +if (Platform.OS === 'android') { + const result = await showBillingProgramInformationDialogAndroid({ + billingProgram: 'billing-choice', + externalTransactionToken: reportingDetails.externalTransactionToken, + }); +}`} + ), + dart: ( + {`if (Platform.isAndroid) { + final result = await FlutterInappPurchase.instance + .showBillingProgramInformationDialogAndroid( + BillingProgramInformationDialogParamsAndroid( + billingProgram: BillingProgramAndroid.BillingChoice, + externalTransactionToken: reportingDetails.externalTransactionToken, + ), + ); +}`} + ), + csharp: ( + {`var result = await ((MutationResolver)OpenIapClient.Instance) + .ShowBillingProgramInformationDialogAndroidAsync( + new BillingProgramInformationDialogParamsAndroid + { + BillingProgram = BillingProgramAndroid.BillingChoice, + ExternalTransactionToken = reportingDetails.ExternalTransactionToken, + });`} + ), + gdscript: ( + {`if iap.get_platform() == "Android": + var params = BillingProgramInformationDialogParamsAndroid.new() + params.billing_program = BillingProgramAndroid.BILLING_CHOICE + params.external_transaction_token = reporting_details.external_transaction_token + var result = await iap.show_billing_program_information_dialog_android(params)`} + ), + }} + +
    + ); +} + +export default ShowBillingProgramInformationDialogAndroid; diff --git a/packages/docs/src/pages/docs/apis/android/show-in-app-messages-android.tsx b/packages/docs/src/pages/docs/apis/android/show-in-app-messages-android.tsx new file mode 100644 index 000000000..562bf3c25 --- /dev/null +++ b/packages/docs/src/pages/docs/apis/android/show-in-app-messages-android.tsx @@ -0,0 +1,201 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../../components/AnchorLink'; +import CodeBlock from '../../../../components/CodeBlock'; +import LanguageTabs from '../../../../components/LanguageTabs'; +import SEO from '../../../../components/SEO'; +import { useScrollToHash } from '../../../../hooks/useScrollToHash'; + +function ShowInAppMessagesAndroid() { + useScrollToHash(); + + return ( +
    + +

    + Android{' '} + showInAppMessagesAndroid +

    +

    + Shows Google Play billing in-app messages, such as transactional + subscription updates. OpenIAP support starts in Spec 2.1.0 and{' '} + openiap-google 2.3.0. The upstream API is available in Play + Billing 4.1.0+; Play Billing 9.0+ also uses transactional messages for + pending subscription price-increase opt-ins. +

    +

    + Wraps BillingClient.showInAppMessages(...). If the result + is 'subscription-status-updated', use the returned{' '} + purchaseToken to refresh the affected subscription state. + Google recommends checking transactional messages whenever the app + opens. This API implements the React Native request tracked in{' '} + + issue #221 + + . +

    + +

    Signature

    + + {{ + kotlin: ( + {`suspend fun showInAppMessages( + activity: Activity, + params: InAppMessageParamsAndroid? = null +): InAppMessageResultAndroid`} + ), + kmp: ( + {`suspend fun showInAppMessagesAndroid( + params: InAppMessageParamsAndroid? = null +): InAppMessageResultAndroid`} + ), + typescript: ( + {`showInAppMessagesAndroid( + params?: InAppMessageParamsAndroid | null +): Promise`} + ), + dart: ( + {`Future showInAppMessagesAndroid([ + InAppMessageParamsAndroid? params, +]);`} + ), + csharp: ( + {`Task ShowInAppMessagesAndroidAsync( + InAppMessageParamsAndroid? @params = null +);`} + ), + gdscript: ( + {`func show_in_app_messages_android( + params: InAppMessageParamsAndroid = null +) -> InAppMessageResultAndroid`} + ), + }} + + + + Parameters + +

    + Optional{' '} + + InAppMessageParamsAndroid + + : +

    +
      +
    • + categories{' '} + + (optional,{' '} + + InAppMessageCategoryAndroid[] | null + + ) + {' '} + - defaults to 'transactional' messages. +
    • +
    + + + Returns + +

    + + Promise<InAppMessageResultAndroid> + {' '} + with: +

    +
      +
    • + responseCode{' '} + + ( + + InAppMessageResponseCodeAndroid + + ) + {' '} + - result of the in-app message flow. +
    • +
    • + purchaseToken{' '} + + (string | null) + {' '} + - present when a subscription status changed. +
    • +
    + +

    Example

    + + {{ + kotlin: ( + {`val result = openIapStore.showInAppMessages( + activity, + InAppMessageParamsAndroid( + categories = listOf(InAppMessageCategoryAndroid.Transactional) + ) +)`} + ), + kmp: ( + {`val result = kmpIAP.showInAppMessagesAndroid( + InAppMessageParamsAndroid( + categories = listOf(InAppMessageCategoryAndroid.Transactional) + ) +)`} + ), + typescript: ( + {`import { showInAppMessagesAndroid } from 'react-native-iap'; +// The same API is exported by expo-iap. + +if (Platform.OS === 'android') { + const result = await showInAppMessagesAndroid({ + categories: ['transactional'], + }); + + if ( + result.responseCode === 'subscription-status-updated' && + result.purchaseToken + ) { + // Refresh this token through your subscription backend. + } +}`} + ), + dart: ( + {`if (Platform.isAndroid) { + final result = await FlutterInappPurchase.instance.showInAppMessagesAndroid( + InAppMessageParamsAndroid( + categories: [InAppMessageCategoryAndroid.Transactional], + ), + ); +}`} + ), + csharp: ( + {`var result = await ((MutationResolver)OpenIapClient.Instance) + .ShowInAppMessagesAndroidAsync(new InAppMessageParamsAndroid + { + Categories = new[] { InAppMessageCategoryAndroid.Transactional }, + });`} + ), + gdscript: ( + {`if iap.get_platform() == "Android": + var params = InAppMessageParamsAndroid.new() + params.categories = [InAppMessageCategoryAndroid.TRANSACTIONAL] + var result = await iap.show_in_app_messages_android(params)`} + ), + }} + +
    + ); +} + +export default ShowInAppMessagesAndroid; diff --git a/packages/docs/src/pages/docs/apis/deep-link-to-subscriptions.tsx b/packages/docs/src/pages/docs/apis/deep-link-to-subscriptions.tsx index 951d35ee8..9be55b802 100644 --- a/packages/docs/src/pages/docs/apis/deep-link-to-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/deep-link-to-subscriptions.tsx @@ -76,7 +76,7 @@ interface DeepLinkOptions { {`Task DeepLinkToSubscriptionsAsync(DeepLinkOptions? options = null);`} ), gdscript: ( - {`func deep_link_to_subscriptions(options: DeepLinkOptions) -> void`} + {`func deep_link_to_subscriptions(options: DeepLinkOptions) -> Types.VoidResult`} ), }} @@ -206,7 +206,7 @@ await ((MutationResolver)OpenIapClient.Instance).DeepLinkToSubscriptionsAsync( {`var options = DeepLinkOptions.new() options.sku_android = "com.app.premium" options.package_name_android = "com.yourcompany.app" -await iap.deep_link_to_subscriptions(options)`} +var result = await iap.deep_link_to_subscriptions(options)`} ), }} diff --git a/packages/docs/src/pages/docs/apis/index.tsx b/packages/docs/src/pages/docs/apis/index.tsx index 54c07ac97..7874da07f 100644 --- a/packages/docs/src/pages/docs/apis/index.tsx +++ b/packages/docs/src/pages/docs/apis/index.tsx @@ -71,10 +71,16 @@ const LEGACY_ANCHOR_REDIRECTS: Record = { '/docs/apis/android/enable-billing-program-android', 'is-billing-program-available-android': '/docs/apis/android/is-billing-program-available-android', + 'get-billing-choice-info-android': + '/docs/apis/android/get-billing-choice-info-android', 'launch-external-link-android': '/docs/apis/android/launch-external-link-android', 'create-billing-program-reporting-details-android': '/docs/apis/android/create-billing-program-reporting-details-android', + 'show-billing-program-information-dialog-android': + '/docs/apis/android/show-billing-program-information-dialog-android', + 'show-in-app-messages-android': + '/docs/apis/android/show-in-app-messages-android', // Validation/Refund/Debugging moved to Features 'verify-purchase': '/docs/features/validation#verify-purchase', 'verify-purchase-with-provider': @@ -706,6 +712,17 @@ function APIsIndex() { available for the current user. + + + + getBillingChoiceInfoAndroid + + + + Fetch Play Billing Choice assets for developer-rendered choice + screens (Play Billing 9.1.0+). + + @@ -728,6 +745,28 @@ function APIsIndex() { Developer-Provided Billing transaction (Play Billing 8.3.0+). + + + + showBillingProgramInformationDialogAndroid + + + + Show Google's Billing Choice information dialog (Play Billing + 9.1.0+). + + + + + + showInAppMessagesAndroid + + + + Show Play billing in-app messages and return subscription status + updates when applicable. + + diff --git a/packages/docs/src/pages/docs/apis/init-connection.tsx b/packages/docs/src/pages/docs/apis/init-connection.tsx index 10659b6af..f5b6ea97c 100644 --- a/packages/docs/src/pages/docs/apis/init-connection.tsx +++ b/packages/docs/src/pages/docs/apis/init-connection.tsx @@ -105,10 +105,27 @@ function InitConnection() { ) {' '} - — Android. Enable a Play Billing 8.2.0+ program ( - EXTERNAL_CONTENT_LINK / EXTERNAL_OFFER) at - connection time. EXTERNAL_PAYMENTS is gated to Billing - 8.3.0+ (Japan only). + — Android. Enable a Play Billing program at + connection time. EXTERNAL_CONTENT_LINK and{' '} + EXTERNAL_OFFER require Billing 8.2.0+;{' '} + EXTERNAL_PAYMENTS requires Billing 8.3.0+ (Japan only); + BILLING_CHOICE is available in OpenIAP Spec 2.1.0 /{' '} + openiap-google 2.3.0 and requires Billing 9.1.0+. +
  • +
  • + billingChoiceScreenTypeAndroid{' '} + + (optional,{' '} + + BillingChoiceScreenTypeAndroid + + ) + {' '} + — OpenIAP Spec 2.1.0 / openiap-google 2.3.0. Requires + Play Billing 9.1.0+ and must match the Billing Choice renderer + configured in Play Console. Defaults to GOOGLE_RENDERED; + set DEVELOPER_RENDERED when your app owns the choice + screen.
  • @@ -142,6 +159,12 @@ await initConnection({ enableBillingProgramAndroid: 'external-offer', }); +// Developer-rendered Billing Choice (must match Play Console) +await initConnection({ + enableBillingProgramAndroid: 'billing-choice', + billingChoiceScreenTypeAndroid: 'developer-rendered', +}); + // --- Or via the useIAP() hook (also exported from react-native-iap) --- // useIAP auto-connects on mount and disconnects on unmount, so you almost // never need to call initConnection() yourself. Pass connection options @@ -166,10 +189,11 @@ try await OpenIapModule.shared.initConnection()`} {`// Standard connection openIapStore.initConnection() -// With alternative billing +// Developer-rendered Billing Choice openIapStore.initConnection( InitConnectionConfig( - alternativeBillingModeAndroid = AlternativeBillingModeAndroid.UserChoice + enableBillingProgramAndroid = BillingProgramAndroid.BillingChoice, + billingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.DeveloperRendered ) )`} ), @@ -181,15 +205,20 @@ val kmpIAP = KmpIAP() // Standard connection kmpIAP.initConnection() -// With alternative billing +// Developer-rendered Billing Choice kmpIAP.initConnection( InitConnectionConfig( - alternativeBillingModeAndroid = AlternativeBillingModeAndroid.UserChoice + enableBillingProgramAndroid = BillingProgramAndroid.BillingChoice, + billingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.DeveloperRendered ) )`} ), dart: ( - {`await FlutterInappPurchase.instance.initConnection();`} + {`await FlutterInappPurchase.instance.initConnection( + enableBillingProgramAndroid: BillingProgramAndroid.BillingChoice, + billingChoiceScreenTypeAndroid: + BillingChoiceScreenTypeAndroid.DeveloperRendered, +);`} ), csharp: ( {`using OpenIap; @@ -198,20 +227,22 @@ using OpenIap.Maui; // Standard connection await ((MutationResolver)OpenIapClient.Instance).InitConnectionAsync(); -// With alternative billing +// Developer-rendered Billing Choice await ((MutationResolver)OpenIapClient.Instance).InitConnectionAsync( new InitConnectionConfig { - EnableBillingProgramAndroid = BillingProgramAndroid.UserChoiceBilling, + EnableBillingProgramAndroid = BillingProgramAndroid.BillingChoice, + BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.DeveloperRendered, });`} ), gdscript: ( {`# Standard connection var success = await iap.init_connection() -# With alternative billing (Android) +# Developer-rendered Billing Choice (Android) var config = InitConnectionConfig.new() -config.alternative_billing_mode_android = AlternativeBillingModeAndroid.USER_CHOICE +config.enable_billing_program_android = BillingProgramAndroid.BILLING_CHOICE +config.billing_choice_screen_type_android = BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED var success = await iap.init_connection(config)`} ), }} @@ -230,6 +261,10 @@ var success = await iap.init_connection(config)`} enableBillingProgramAndroid + , and{' '} + + billingChoiceScreenTypeAndroid + ).

    diff --git a/packages/docs/src/pages/docs/apis/ios/begin-refund-request-ios.tsx b/packages/docs/src/pages/docs/apis/ios/begin-refund-request-ios.tsx index 503dad89e..4d8c95b6e 100644 --- a/packages/docs/src/pages/docs/apis/ios/begin-refund-request-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/begin-refund-request-ios.tsx @@ -56,7 +56,7 @@ function BeginRefundRequestIOS() { {`Task BeginRefundRequestIOSAsync(String Sku)`} ), gdscript: ( - {`func begin_refund_request_ios(product_id: String) -> Types.RefundResultIOS`} + {`func begin_refund_request_ios(product_id: String) -> String`} ), }} @@ -117,8 +117,7 @@ var status = await ((MutationResolver)OpenIapClient.Instance) ), gdscript: ( {`if iap.get_platform() == "iOS": - # Synchronous — no await; returns Types.RefundResultIOS directly. - var result = iap.begin_refund_request_ios("com.app.premium")`} + var status = await iap.begin_refund_request_ios("com.app.premium")`} ), }} diff --git a/packages/docs/src/pages/docs/apis/ios/clear-transaction-ios.tsx b/packages/docs/src/pages/docs/apis/ios/clear-transaction-ios.tsx index 3985b3ff4..065a33be2 100644 --- a/packages/docs/src/pages/docs/apis/ios/clear-transaction-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/clear-transaction-ios.tsx @@ -53,7 +53,7 @@ function ClearTransactionIOS() { {`Task ClearTransactionIOSAsync();`} ), gdscript: ( - {`func clear_transaction_ios() -> Variant`} + {`func clear_transaction_ios() -> bool`} ), }} diff --git a/packages/docs/src/pages/docs/apis/ios/present-code-redemption-sheet-ios.tsx b/packages/docs/src/pages/docs/apis/ios/present-code-redemption-sheet-ios.tsx index ba9f9eac7..510ad7c27 100644 --- a/packages/docs/src/pages/docs/apis/ios/present-code-redemption-sheet-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/present-code-redemption-sheet-ios.tsx @@ -55,7 +55,7 @@ function PresentCodeRedemptionSheetIOS() { {`Task PresentCodeRedemptionSheetIOSAsync();`} ), gdscript: ( - {`func present_code_redemption_sheet_ios() -> Types.VoidResult`} + {`func present_code_redemption_sheet_ios() -> bool`} ), }} diff --git a/packages/docs/src/pages/docs/apis/ios/request-purchase-on-promoted-product-ios.tsx b/packages/docs/src/pages/docs/apis/ios/request-purchase-on-promoted-product-ios.tsx index d5f90c4f8..90f0fb8fb 100644 --- a/packages/docs/src/pages/docs/apis/ios/request-purchase-on-promoted-product-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/request-purchase-on-promoted-product-ios.tsx @@ -69,7 +69,7 @@ Future requestPurchaseOnPromotedProductIOS();`} Task RequestPurchaseOnPromotedProductIOSAsync();`} ), gdscript: ( - {`func request_purchase_on_promoted_product_ios() -> Variant`} + {`func request_purchase_on_promoted_product_ios() -> bool`} ), }} diff --git a/packages/docs/src/pages/docs/apis/ios/sync-ios.tsx b/packages/docs/src/pages/docs/apis/ios/sync-ios.tsx index e59238e1a..dd360ea9c 100644 --- a/packages/docs/src/pages/docs/apis/ios/sync-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/sync-ios.tsx @@ -52,7 +52,7 @@ function SyncIOS() { {`Task SyncIOSAsync();`} ), gdscript: ( - {`func sync_ios() -> Types.VoidResult`} + {`func sync_ios() -> bool`} ), }} diff --git a/packages/docs/src/pages/docs/events.tsx b/packages/docs/src/pages/docs/events.tsx index cc8ef6bfb..c66955757 100644 --- a/packages/docs/src/pages/docs/events.tsx +++ b/packages/docs/src/pages/docs/events.tsx @@ -215,7 +215,8 @@ public enum IapEvent Fires when a user selects developer-provided billing in the - External Payments flow (8.3.0+, Japan only). + External Payments flow (8.3.0+) or Google-rendered Billing + Choice flow (9.1.0+). diff --git a/packages/docs/src/pages/docs/events/android/developer-provided-billing-listener-android.tsx b/packages/docs/src/pages/docs/events/android/developer-provided-billing-listener-android.tsx index 18697d4bd..0719c61cb 100644 --- a/packages/docs/src/pages/docs/events/android/developer-provided-billing-listener-android.tsx +++ b/packages/docs/src/pages/docs/events/android/developer-provided-billing-listener-android.tsx @@ -11,21 +11,26 @@ function DeveloperProvidedBillingListenerAndroid() {

    Android{' '} developerProvidedBillingListenerAndroid

    - Fired when a user selects developer-provided billing in the External - Payments flow on Android. This is different from User Choice Billing - - it presents a side-by-side choice dialog in the purchase flow itself. + Fired when a user selects developer-provided billing in an External + Payments or Billing Choice purchase flow. Billing Choice payload fields + are available in OpenIAP Spec 2.1.0 and openiap-google{' '} + 2.3.0, and require Play Billing 9.1.0+. Unlike User Choice Billing, this + event is tied to the developer billing option configured for the + purchase itself.

    - Note: Currently only available in Japan. + Eligibility: Availability depends on the enabled Google + Play billing program and market. External Payments launched in Japan; + Billing Choice has its own program eligibility.

    Listener Setup

    @@ -62,16 +67,32 @@ using OpenIap.Maui; // Observable callback approach. IDisposable subscription = OpenIapClient.Instance.DeveloperProvidedBillingAndroid.Subscribe(details => { - Console.WriteLine("External transaction token received; send it to your backend without logging it."); + Console.WriteLine($"Developer billing selected for {details.Products.Count} product(s)."); });`} ), }}

    Registers a listener for Developer Provided Billing events. This - listener is only triggered when the user selects the developer's payment - option (instead of Google Play) in the External Payments flow. + listener is triggered when the user selects the developer's payment + option instead of Google Play in an enabled developer billing program.

    +

    + React Native and Expo hook users can pass the same callback through{' '} + onDeveloperProvidedBillingAndroid. The hook also accepts{' '} + billingChoiceScreenTypeAndroid and returns the Billing + Choice Android APIs. +

    + {`const { + getBillingChoiceInfoAndroid, + showInAppMessagesAndroid, +} = useIAP({ + enableBillingProgramAndroid: 'billing-choice', + billingChoiceScreenTypeAndroid: 'google-rendered', + onDeveloperProvidedBillingAndroid: async (details) => { + await processDeveloperBilling(details); + }, +});`} {{ @@ -79,18 +100,18 @@ IDisposable subscription = OpenIapClient.Instance.DeveloperProvidedBillingAndroi {`import { developerProvidedBillingListenerAndroid } from 'expo-iap'; const subscription = developerProvidedBillingListenerAndroid(async (details) => { - console.log('User selected developer billing'); - console.log('External transaction token received; send it to your backend without logging it.'); - - // Process payment with your payment system + // Use products and linkUri to choose your in-app or external-link checkout. const paymentResult = await processPaymentWithYourGateway({ - token: details.externalTransactionToken, - // Your payment details + products: details.products, + linkUri: details.linkUri, }); if (paymentResult.success) { - // IMPORTANT: Report the token to Google Play within 24 hours - await reportExternalTransactionToGoogle(details.externalTransactionToken); + if (details.externalTransactionToken) { + await reportExternalTransactionToGoogle( + details.externalTransactionToken, + ); + } grantUserAccess(); } }); @@ -103,18 +124,16 @@ subscription.remove();`} // Using callback openIapStore.addDeveloperProvidedBillingListener { details -> - println("User selected developer billing") - println("External transaction token received; send it to your backend without logging it.") - lifecycleScope.launch { - // Process payment with your payment system val paymentResult = processPaymentWithYourGateway( - token = details.externalTransactionToken + products = details.products, + linkUri = details.linkUri ) if (paymentResult.success) { - // IMPORTANT: Report the token to Google Play within 24 hours - reportExternalTransactionToGoogle(details.externalTransactionToken) + details.externalTransactionToken?.let { token -> + reportExternalTransactionToGoogle(token) + } grantUserAccess() } } @@ -127,18 +146,16 @@ val kmpIAP = KmpIAP() // Using callback kmpIAP.addDeveloperProvidedBillingListener { details -> - println("User selected developer billing") - println("External transaction token received; send it to your backend without logging it.") - lifecycleScope.launch { - // Process payment with your payment system val paymentResult = processPaymentWithYourGateway( - token = details.externalTransactionToken + products = details.products, + linkUri = details.linkUri ) if (paymentResult.success) { - // IMPORTANT: Report the token to Google Play within 24 hours - reportExternalTransactionToGoogle(details.externalTransactionToken) + details.externalTransactionToken?.let { token -> + reportExternalTransactionToGoogle(token) + } grantUserAccess() } } @@ -150,17 +167,16 @@ kmpIAP.addDeveloperProvidedBillingListener { details -> // Android only (8.3.0+) - will not fire on iOS or older Android final subscription = FlutterInappPurchase.developerProvidedBillingStream .listen((details) async { - print('User selected developer billing'); - print('External transaction token received; send it to your backend without logging it.'); - - // Process payment with your payment system final paymentResult = await processPaymentWithYourGateway( - token: details.externalTransactionToken, + products: details.products, + linkUri: details.linkUri, ); if (paymentResult.success) { - // IMPORTANT: Report the token to Google Play within 24 hours - await reportExternalTransactionToGoogle(details.externalTransactionToken); + final token = details.externalTransactionToken; + if (token != null) { + await reportExternalTransactionToGoogle(token); + } grantUserAccess(); } }); @@ -174,16 +190,16 @@ using OpenIap.Maui; var subscription = OpenIapClient.Instance.DeveloperProvidedBillingAndroid.Subscribe(async details => { - Console.WriteLine("User selected developer billing"); - Console.WriteLine("External transaction token received; send it to your backend without logging it."); - var paymentResult = await ProcessPaymentWithYourGatewayAsync( - details.ExternalTransactionToken); + details.Products, + details.LinkUri); if (paymentResult.Success) { - // IMPORTANT: Report the token to Google Play within 24 hours. - await ReportExternalTransactionToGoogleAsync(details.ExternalTransactionToken); + if (details.ExternalTransactionToken is { Length: > 0 } token) + { + await ReportExternalTransactionToGoogleAsync(token); + } await GrantUserAccessAsync(); } }); @@ -199,7 +215,16 @@ subscription.Dispose();`} {{ typescript: ( {`interface DeveloperProvidedBillingDetailsAndroid { - externalTransactionToken: string; + externalTransactionToken?: string | null; + linkUri?: string | null; + originalExternalTransactionId?: string | null; + products: DeveloperProvidedBillingProductAndroid[]; +} + +interface DeveloperProvidedBillingProductAndroid { + id: string; + type: 'in-app' | 'subs'; + offerToken?: string | null; }`} ), swift: ( @@ -207,17 +232,26 @@ subscription.Dispose();`} ), kotlin: ( {`data class DeveloperProvidedBillingDetailsAndroid( - val externalTransactionToken: String + val externalTransactionToken: String?, + val linkUri: String?, + val originalExternalTransactionId: String?, + val products: List )`} ), kmp: ( {`data class DeveloperProvidedBillingDetailsAndroid( - val externalTransactionToken: String + val externalTransactionToken: String?, + val linkUri: String?, + val originalExternalTransactionId: String?, + val products: List )`} ), dart: ( {`class DeveloperProvidedBillingDetailsAndroid { - final String externalTransactionToken; + final String? externalTransactionToken; + final String? linkUri; + final String? originalExternalTransactionId; + final List products; }`} ), csharp: ( @@ -226,14 +260,19 @@ using OpenIap.Maui; public sealed record DeveloperProvidedBillingDetailsAndroid { - public required string ExternalTransactionToken { get; init; } + public string? ExternalTransactionToken { get; init; } + public string? LinkUri { get; init; } + public string? OriginalExternalTransactionId { get; init; } + public required IReadOnlyList Products { get; init; } }`} ), }}

    - externalTransactionToken - Token that must be reported - to Google Play within 24 hours after completing the payment + externalTransactionToken, linkUri, and{' '} + originalExternalTransactionId are nullable. The callback + always includes products; each item has an id,{' '} + type, and optional subscription offerToken.

    Comparison: User Choice vs Developer Provided Billing

    @@ -254,7 +293,10 @@ public sealed record DeveloperProvidedBillingDetailsAndroid Availability Eligible regions - Japan only + + Program and market eligibility; External Payments launched in + Japan, while Billing Choice uses separate eligibility. + When presented @@ -264,7 +306,7 @@ public sealed record DeveloperProvidedBillingDetailsAndroid UI Separate dialog before purchase - Side-by-side choice in purchase dialog + Program-specific in-app or external-link choice flow Event @@ -281,7 +323,8 @@ public sealed record DeveloperProvidedBillingDetailsAndroid AlternativeBillingModeAndroid.UserChoice - enableBillingProgram(EXTERNAL_PAYMENTS) +{' '} + enableBillingProgram(EXTERNAL_PAYMENTS) or{' '} + enableBillingProgram(BILLING_CHOICE) +{' '} developerBillingOption in requestPurchase @@ -298,19 +341,23 @@ public sealed record DeveloperProvidedBillingDetailsAndroid }} >

    - ⚠️ Important: The external transaction token MUST be - reported to Google Play within 24 hours using the{' '} - externaltransactions.createexternaltransaction API. - Failure to report tokens may result in account suspension. + Important: When Google Play returns an external + transaction token, send it to your backend and report the completed + external transaction within the applicable program deadline. Do not + log or expose the token.

    See{' '} - - External Payments documentation + + Billing Programs types + {' '} + and the{' '} + + external purchase guide {' '} - for complete implementation examples. + for setup and purchase examples.

    ); diff --git a/packages/docs/src/pages/docs/features/external-purchase.tsx b/packages/docs/src/pages/docs/features/external-purchase.tsx index 6cf6e7e62..e4b093f9c 100644 --- a/packages/docs/src/pages/docs/features/external-purchase.tsx +++ b/packages/docs/src/pages/docs/features/external-purchase.tsx @@ -1419,7 +1419,7 @@ func _ready() -> void: await iap.init_connection(config) # Set user choice billing listener (for alternative billing selection) - iap.user_choice_billing.connect(_on_user_choice_billing) + iap.user_choice_billing_android.connect(_on_user_choice_billing) iap.purchase_updated.connect(_on_purchase_updated) func _on_user_choice_billing(details: UserChoiceBillingDetails) -> void: @@ -1457,7 +1457,7 @@ func handle_user_choice_purchase(product_id: String) -> void: await iap.request_purchase(props) # If user selects Google Play -> purchase_updated signal - # If user selects alternative -> user_choice_billing signal`} + # If user selects alternative -> user_choice_billing_android signal`} ), }} @@ -2404,10 +2404,10 @@ func _ready() -> void: await iap.init_connection(null) # Step 1: Set up listener for when user selects developer billing - iap.developer_provided_billing.connect(_on_developer_provided_billing) + iap.developer_provided_billing_android.connect(_on_developer_provided_billing) iap.purchase_updated.connect(_on_purchase_updated) -func _on_developer_provided_billing(details: DeveloperProvidedBillingDetails) -> void: +func _on_developer_provided_billing(details: DeveloperProvidedBillingDetailsAndroid) -> void: print("User selected developer billing") print("External transaction token received; send it to your backend without logging it.") @@ -2454,7 +2454,7 @@ func handle_purchase_with_external_payments(product_id: String) -> void: await iap.request_purchase(props) # If user selects Google Play -> purchase_updated signal - # If user selects developer billing -> developer_provided_billing signal`} + # If user selects developer billing -> developer_provided_billing_android signal`} ), }} diff --git a/packages/docs/src/pages/docs/foundation/one-pager.tsx b/packages/docs/src/pages/docs/foundation/one-pager.tsx index 6b214cf75..8023c495d 100644 --- a/packages/docs/src/pages/docs/foundation/one-pager.tsx +++ b/packages/docs/src/pages/docs/foundation/one-pager.tsx @@ -191,7 +191,7 @@ function OnePager() { Reference implementations for Apple StoreKit 2 and Google Play - Billing 8.x + Billing 9.1.0 @@ -380,7 +380,7 @@ function OnePager() {
  • Store APIs Supported: Apple StoreKit 2, Google Play - Billing 8.x, Meta Horizon 1.1 + Billing 9.1.0, Meta Horizon 1.1
  • Sponsor: Meta (founding sponsor) @@ -440,7 +440,7 @@ function OnePager() {
  • Platform API churn: Both Apple (StoreKit 2) and - Google (Billing 8.x) have made breaking changes in recent years + Google (Billing 8.x/9.x) have made breaking changes in recent years
  • Security scrutiny: App store fraud and receipt diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index d07f8375d..d15cfa040 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -91,8 +91,11 @@ import APIsShowAlternativeBillingDialogAndroid from './apis/android/show-alterna import APIsCreateAlternativeBillingTokenAndroid from './apis/android/create-alternative-billing-token-android'; import APIsEnableBillingProgramAndroid from './apis/android/enable-billing-program-android'; import APIsIsBillingProgramAvailableAndroid from './apis/android/is-billing-program-available-android'; +import APIsGetBillingChoiceInfoAndroid from './apis/android/get-billing-choice-info-android'; import APIsLaunchExternalLinkAndroid from './apis/android/launch-external-link-android'; import APIsCreateBillingProgramReportingDetailsAndroid from './apis/android/create-billing-program-reporting-details-android'; +import APIsShowBillingProgramInformationDialogAndroid from './apis/android/show-billing-program-information-dialog-android'; +import APIsShowInAppMessagesAndroid from './apis/android/show-in-app-messages-android'; import Events from './events'; import Webhooks from './webhooks'; import KitBackend from './kit-backend'; @@ -682,6 +685,10 @@ function Docs() { to: '/docs/apis/android/is-billing-program-available-android', label: 'isBillingProgramAvailableAndroid', }, + { + to: '/docs/apis/android/get-billing-choice-info-android', + label: 'getBillingChoiceInfoAndroid', + }, { to: '/docs/apis/android/launch-external-link-android', label: 'launchExternalLinkAndroid', @@ -690,6 +697,14 @@ function Docs() { to: '/docs/apis/android/create-billing-program-reporting-details-android', label: 'createBillingProgramReportingDetailsAndroid', }, + { + to: '/docs/apis/android/show-billing-program-information-dialog-android', + label: 'showBillingProgramInformationDialogAndroid', + }, + { + to: '/docs/apis/android/show-in-app-messages-android', + label: 'showInAppMessagesAndroid', + }, ], }, ]} @@ -1284,6 +1299,10 @@ function Docs() { path="apis/android/is-billing-program-available-android" element={} /> + } + /> } @@ -1292,6 +1311,14 @@ function Docs() { path="apis/android/create-billing-program-reporting-details-android" element={} /> + } + /> + } + /> } diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx index 14b1b5c68..2514ac1b7 100644 --- a/packages/docs/src/pages/docs/kit-backend.tsx +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -177,6 +177,15 @@ function KitBackend() { verifyPurchaseWithProvider in the SDKs; the snippets below call it directly.

    +

    + A verification result and the IAPKit Purchases row are snapshots of + the store response at verification time. On Android, a valid new + purchase may first return pending-acknowledgment. Call{' '} + finishTransaction, then verify the same token again when + the purchase log must reflect entitled. Use subscription + status, entitlements, and store webhooks for current lifecycle state; + purchase snapshots otherwise change only when they are verified again. +

    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 diff --git a/packages/docs/src/pages/docs/setup/godot.tsx b/packages/docs/src/pages/docs/setup/godot.tsx index 4e7e263a8..7c44ac9ef 100644 --- a/packages/docs/src/pages/docs/setup/godot.tsx +++ b/packages/docs/src/pages/docs/setup/godot.tsx @@ -45,7 +45,7 @@ function GodotSetup() {

  • iOS: Xcode 16+ (Swift 6.0+) with{' '} - iOS 15+ target + iOS 17+ target
  • Android: Android SDK with API level 24+ diff --git a/packages/docs/src/pages/docs/setup/store/horizon.tsx b/packages/docs/src/pages/docs/setup/store/horizon.tsx index 84a00db5c..f5e649781 100644 --- a/packages/docs/src/pages/docs/setup/store/horizon.tsx +++ b/packages/docs/src/pages/docs/setup/store/horizon.tsx @@ -44,18 +44,15 @@ function HorizonStoreSetup() { Horizon app id - Meta Horizon Developer Hub app record. In native Android this is - commonly injected as OCULUS_APP_ID or{' '} + Meta Horizon Developer Hub app record. Gradle projects commonly + expose it through a placeholder named{' '} HORIZON_APP_ID. Expo uses android.horizon.appId. Bare React Native and Flutter examples below use a Gradle property named{' '} horizonAppId and write Android manifest meta-data{' '} - com.meta.horizon.platform.ovr.OCULUS_APP_ID. - OpenIAP also accepts{' '} - com.meta.horizon.platform.ovr.HORIZON_APP_ID for - host apps that already use that key. + com.meta.horizon.platform.HORIZON_APP_ID. @@ -185,13 +182,13 @@ android { }`}

    Provide the app id in the Android manifest:

    {``}

    - If the host app already uses{' '} - com.meta.horizon.platform.ovr.HORIZON_APP_ID, OpenIAP can - read that key as well. Prefer OCULUS_APP_ID for new app - setup because the Expo plugin writes that key. + OpenIAP still reads the older ...ovr.OCULUS_APP_ID,{' '} + ...ovr.HORIZON_APP_ID, and{' '} + com.oculus.vr.APP_ID keys for migration. New setup should + use the Horizon 2.x canonical key above.

    @@ -250,7 +247,7 @@ android { } }`} {``} diff --git a/packages/docs/src/pages/docs/types/alternative-billing-types.tsx b/packages/docs/src/pages/docs/types/alternative-billing-types.tsx index ae9b348f1..b47ce4f13 100644 --- a/packages/docs/src/pages/docs/types/alternative-billing-types.tsx +++ b/packages/docs/src/pages/docs/types/alternative-billing-types.tsx @@ -157,7 +157,24 @@ function AlternativeBillingTypes() { during connection. Use USER_CHOICE_BILLING for user choice, EXTERNAL_OFFER for alternative only, or{' '} EXTERNAL_PAYMENTS for Japan external payments - (8.3.0+). + (8.3.0+). Use BILLING_CHOICE for Billing Choice + (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; Play Billing + 9.1.0+). + + + + + + billingChoiceScreenTypeAndroid + + + + Billing Choice renderer in OpenIAP Spec 2.1.0 / openiap-google + 2.3.0 (Play Billing 9.1.0+). Defaults to{' '} + GOOGLE_RENDERED. Set{' '} + DEVELOPER_RENDERED when your app renders the choice + screen; this controls whether OpenIAP registers Play's + developer-provided billing listener. @@ -201,6 +218,12 @@ await initConnection({ enableBillingProgramAndroid: 'external-payments' }); +// Developer-rendered Billing Choice (must match Play Console, 9.1.0+) +await initConnection({ + enableBillingProgramAndroid: 'billing-choice', + billingChoiceScreenTypeAndroid: 'developer-rendered' +}); + // Standard billing (default) await initConnection();`} ), @@ -234,6 +257,14 @@ openIapStore.initConnection( ) ) +// Developer-rendered Billing Choice (must match Play Console, 9.1.0+) +openIapStore.initConnection( + InitConnectionConfig( + enableBillingProgramAndroid = BillingProgramAndroid.BillingChoice, + billingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.DeveloperRendered + ) +) + // Standard billing (default) openIapStore.initConnection()`} ), @@ -253,6 +284,13 @@ await FlutterInappPurchase.instance.initConnection( enableBillingProgramAndroid: BillingProgramAndroid.ExternalPayments, ); +// Developer-rendered Billing Choice (must match Play Console, 9.1.0+) +await FlutterInappPurchase.instance.initConnection( + enableBillingProgramAndroid: BillingProgramAndroid.BillingChoice, + billingChoiceScreenTypeAndroid: + BillingChoiceScreenTypeAndroid.DeveloperRendered, +); + // Standard billing (default) await FlutterInappPurchase.instance.initConnection();`} ), @@ -281,6 +319,14 @@ await ((MutationResolver)OpenIapClient.Instance).InitConnectionAsync( EnableBillingProgramAndroid = BillingProgramAndroid.ExternalPayments, }); +// Developer-rendered Billing Choice (must match Play Console, 9.1.0+) +await ((MutationResolver)OpenIapClient.Instance).InitConnectionAsync( + new InitConnectionConfig + { + EnableBillingProgramAndroid = BillingProgramAndroid.BillingChoice, + BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.DeveloperRendered, + }); + // Standard billing (default) await ((MutationResolver)OpenIapClient.Instance).InitConnectionAsync();`} ), @@ -298,6 +344,11 @@ await iap.init_connection(config) config.enable_billing_program_android = BillingProgramAndroid.EXTERNAL_PAYMENTS await iap.init_connection(config) +# Developer-rendered Billing Choice (must match Play Console, 9.1.0+) +config.enable_billing_program_android = BillingProgramAndroid.BILLING_CHOICE +config.billing_choice_screen_type_android = BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED +await iap.init_connection(config) + # Standard billing (default) await iap.init_connection()`} ), @@ -541,7 +592,7 @@ func _on_user_choice_billing(details: UserChoiceBillingDetails): if payment_result.success: grant_user_access() -iap.user_choice_billing.connect(_on_user_choice_billing) +iap.user_choice_billing_android.connect(_on_user_choice_billing) # Step 2: Initialize with user choice billing (recommended) var config = InitConnectionConfig.new() @@ -563,7 +614,7 @@ props.type = ProductType.SUBS await iap.request_purchase(props) # If user selects Google Play → purchase_updated signal fires -# If user selects alternative → user_choice_billing signal fires`} +# If user selects alternative → user_choice_billing_android signal fires`} ), }} diff --git a/packages/docs/src/pages/docs/types/billing-programs.tsx b/packages/docs/src/pages/docs/types/billing-programs.tsx index 297c46ec3..020777593 100644 --- a/packages/docs/src/pages/docs/types/billing-programs.tsx +++ b/packages/docs/src/pages/docs/types/billing-programs.tsx @@ -24,11 +24,12 @@ function BillingPrograms() {

    Google Play Billing Library 8.2.0+ introduces the Billing Programs API, which provides a more structured approach to external offers and - content links. Version 8.3.0 adds External Payments for Japan. + content links. Version 8.3.0 adds External Payments for Japan, and + 9.1.0 adds Billing Choice. OpenIAP exposes Billing Choice in Spec + 2.1.0 and openiap-google 2.3.0.

    - Identifiers for Play Billing 8.2.0+ programs (External Payments, - etc.). Android only ( + Identifiers for Play Billing programs. Android only ( 8.3.0 release notes + {' · '} + + 9.1.0 release notes +

    @@ -120,6 +129,16 @@ function BillingPrograms() { 8.3.0+ + + + BILLING_CHOICE + + + Present Google Play Billing alongside an alternative in-app + billing system or external web link + + 9.1.0+ + @@ -162,6 +181,75 @@ function BillingPrograms() { The billing program that was checked + + + choiceScreenType + + + + BillingChoiceScreenTypeAndroid + + | null + + + Billing Choice renderer. Present only for available{' '} + BILLING_CHOICE checks. + + + + + isExternalLinkAvailable + + + boolean | null + + + Whether external-link developer billing is available for Billing + Choice. + + + + + + + BillingChoiceScreenTypeAndroid + +

    How the Billing Choice screen should be rendered (9.1.0+):

    + + + + + + + + + + + + + + + + + + + +
    NameSummary
    + UNSPECIFIED + Unspecified renderer.
    + DEVELOPER_RENDERED + + The app renders the Billing Choice screen using{' '} + + getBillingChoiceInfoAndroid() + + . +
    + GOOGLE_RENDERED + + Google Play renders the Billing Choice screen and information + dialog. +
    @@ -210,6 +298,259 @@ function BillingPrograms() { + + DeveloperBillingTypeAndroid + +

    + Developer billing destination type used when creating Billing Choice + reporting details (9.1.0+): +

    + + + + + + + + + + + + + + + + + + + + + +
    NameSummary
    + DEVELOPER_BILLING_TYPE_UNSPECIFIED + Unspecified type. Do not use.
    + IN_APP + Developer-provided billing inside the app.
    + EXTERNAL_LINK + Developer-provided billing via an external link.
    + + + BillingResultAndroid + +

    Billing operation result returned by Billing Choice dialogs:

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeSummary
    + responseCode + + number + Play Billing response code.
    + debugMessage + + string | null + Optional Play Billing debug message.
    + subResponseCode + + SubResponseCodeAndroid | null + Optional granular response code.
    + + + BillingChoiceImageLayoutAndroid + +

    Image layout requested for developer-rendered Billing Choice:

    + + + + + + + + + + + + + + + + + + + + + +
    NameSummary
    + RECTANGULAR_FOUR_BY_ONE + Rectangular image with a 4:1 aspect ratio.
    + RECTANGULAR_THREE_BY_ONE + Rectangular image with a 3:1 aspect ratio.
    + RECTANGULAR_TWO_BY_TWO + Rectangular image with a 2:2 aspect ratio.
    + + + GetBillingChoiceInfoParamsAndroid + +

    + Parameters for{' '} + + getBillingChoiceInfoAndroid() + + : +

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeSummary
    + billingProgram + + + BillingProgramAndroid + + + Billing program. Defaults to BILLING_CHOICE. +
    + playBillingChoiceImageLayout + + + BillingChoiceImageLayoutAndroid + + + Requested image layout. Defaults to{' '} + RECTANGULAR_FOUR_BY_ONE. +
    + userLocale + + string | null + Optional BCP 47 locale tag.
    + + + BillingChoiceInfoAndroid + +

    + Display information for developer-rendered Billing Choice screens: +

    + + + + + + + + + + + + + + + + + + + + +
    NameTypeSummary
    + playBillingChoiceImageUrl + + string + Play Billing image URL for the requested layout.
    + playBillingLoyaltyInfo + + string | null + Optional Play loyalty information for the user.
    + + + BillingProgramInformationDialogParamsAndroid + +

    + Parameters for{' '} + + showBillingProgramInformationDialogAndroid() + + : +

    + + + + + + + + + + + + + + + + + + + + +
    NameTypeSummary
    + billingProgram + + + BillingProgramAndroid + + + Billing program. Defaults to BILLING_CHOICE. +
    + externalTransactionToken + + string + Billing Choice reporting token.
    + LaunchExternalLinkParamsAndroid @@ -239,8 +580,20 @@ function BillingPrograms() { - The billing program (EXTERNAL_CONTENT_LINK or{' '} - EXTERNAL_OFFER) + The billing program (EXTERNAL_CONTENT_LINK,{' '} + EXTERNAL_OFFER, or BILLING_CHOICE) + + + + + externalTransactionToken + + + string | null + + + Pre-generated token for a developer-rendered Billing Choice + external-link flow (9.1.0+) @@ -353,7 +706,7 @@ function BillingPrograms() {

    Parameters for configuring developer billing option in purchase flow - (8.3.0+): + (8.3.0+; Billing Choice also uses this shape in 9.1.0+):

    @@ -374,7 +727,8 @@ function BillingPrograms() { @@ -382,9 +736,12 @@ function BillingPrograms() { linkUri + - + + + + + + -
    - The billing program (usually EXTERNAL_PAYMENTS) + The billing program (usually EXTERNAL_PAYMENTS or{' '} + BILLING_CHOICE)
    - string + string | null + + URL for an external-link flow. Omit it for an in-app Billing + Choice flow. URL where the external payment will be processed
    @@ -394,11 +751,32 @@ function BillingPrograms() { DeveloperBillingLaunchModeAndroid + | null + + How to launch an external link. Omit it when no link is used. +
    + externalTransactionToken + + string | null + + Optional pre-generated token for a Billing Choice external-link + flow. How to launch the external payment link
    +

    + For an in-app Billing Choice flow, pass only{' '} + billingProgram: BILLING_CHOICE. Add linkUri,{' '} + launchMode, and optionally{' '} + externalTransactionToken only for an external-link flow. +

    DeveloperBillingLaunchModeAndroid @@ -434,7 +812,10 @@ function BillingPrograms() { DeveloperProvidedBillingDetailsAndroid -

    Details received when user selects developer billing (8.3.0+):

    +

    + Details received when a user selects developer billing (8.3.0+; + expanded in Billing 9.0 and 9.1): +

    @@ -448,12 +829,221 @@ function BillingPrograms() { + + + + + + + + + + + + + + + + + + + +
    externalTransactionToken + string | null + + Token used for external transaction reporting when one is + returned for the selected flow. +
    + linkUri + + string | null + Link for a Billing Choice external-link flow.
    + originalExternalTransactionId + + string | null + + Original developer-billed subscription transaction being + replaced. +
    + products + + DeveloperProvidedBillingProductAndroid[] + Products selected for the developer billing flow.
    + + + DeveloperProvidedBillingProductAndroid + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeSummary
    + id + string Google Play product identifier.
    - Token to report external transaction to Google (must report - within 24 hours) + type + + ProductType + + Normalized product type: IN_APP or{' '} + SUBS. +
    + offerToken + + string | null + Subscription offer token, when applicable.
    + + + InAppMessageCategoryAndroid + +

    In-app billing message categories:

    + + + + + + + + + + + + + + + + + +
    NameSummary
    + UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID + Unknown category. Do not use.
    + TRANSACTIONAL + + Transactional billing messages, such as subscription status + changes. +
    + + + InAppMessageParamsAndroid + +

    + Parameters for{' '} + + showInAppMessagesAndroid() + + : +

    + + + + + + + + + + + + + + + +
    NameTypeSummary
    + categories + + + InAppMessageCategoryAndroid[] + + | null + + Categories to show. Defaults to TRANSACTIONAL. +
    + + + InAppMessageResponseCodeAndroid + +

    Result code returned by Play billing in-app messages:

    + + + + + + + + + + + + + + + + + +
    NameSummary
    + NO_ACTION_NEEDED + Flow finished and no developer action is needed.
    + SUBSCRIPTION_STATUS_UPDATED + + Subscription status changed; refresh the purchase referenced by + the returned token. +
    + + + InAppMessageResultAndroid + +

    Result of showing Play billing in-app messages:

    + + + + + + + + + + + + + + + + + + @@ -631,7 +1221,7 @@ func _on_developer_provided_billing(details: DeveloperProvidedBillingDetailsAndr print("External transaction token received; send it to your backend without logging it.") # Report token to Google via your backend within 24 hours -iap.developer_provided_billing.connect(_on_developer_provided_billing) +iap.developer_provided_billing_android.connect(_on_developer_provided_billing) # Check availability (Japan only) var result = await iap.is_billing_program_available_android( diff --git a/packages/docs/src/pages/docs/types/request-purchase-props.tsx b/packages/docs/src/pages/docs/types/request-purchase-props.tsx index 4c16b1a81..74ed16c4c 100644 --- a/packages/docs/src/pages/docs/types/request-purchase-props.tsx +++ b/packages/docs/src/pages/docs/types/request-purchase-props.tsx @@ -514,8 +514,11 @@ await iap.request_purchase(subs_props)`}developerBillingOption + + + + diff --git a/packages/google/ALTERNATIVE_BILLING.md b/packages/google/ALTERNATIVE_BILLING.md index d7c68b710..46e4a8bc7 100644 --- a/packages/google/ALTERNATIVE_BILLING.md +++ b/packages/google/ALTERNATIVE_BILLING.md @@ -10,7 +10,7 @@ Alternative Billing Only allows you to use your own payment system instead of Go - ✅ Google Play Console enrollment in Alternative Billing program - ✅ Google approval (can take several weeks) -- ✅ Billing Library 6.2+ (this library uses 8.0.0) +- ✅ Billing Library 6.2+ (this library uses 9.1.0) - ✅ Country/region eligibility - ✅ Backend server for reporting transactions to Google Play @@ -308,7 +308,7 @@ See [AlternativeBillingScreen.kt](Example/src/main/java/dev/hyo/martie/screens/A ### "enableAlternativeBillingOnly() method not found" - **Cause**: Billing Library version < 6.2 -- **Fix**: Update to Billing Library 6.2+ (this library uses 8.0.0) +- **Fix**: Update to Billing Library 6.2+ (this library uses 9.1.0) ### "Google Play dialog appears instead of alternative billing" - **Cause**: `enableAlternativeBillingOnly()` not called on BillingClient diff --git a/packages/google/Example/build.gradle.kts b/packages/google/Example/build.gradle.kts index 07c579692..93b148aee 100644 --- a/packages/google/Example/build.gradle.kts +++ b/packages/google/Example/build.gradle.kts @@ -66,7 +66,7 @@ android { ?: "" buildConfigField("String", "HORIZON_APP_ID", "\"${appId}\"") // Ensure placeholder exists for all variants (play included) - manifestPlaceholders["OCULUS_APP_ID"] = appId + manifestPlaceholders["HORIZON_APP_ID"] = appId // IAPKit API Key for purchase verification val iapkitApiKey = localProperties.getProperty("iapkit.api.key") @@ -90,11 +90,11 @@ android { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"horizon\"") - // Dynamically inject OCULUS_APP_ID into AndroidManifest + // Dynamically inject the Horizon App ID into AndroidManifest val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID") ?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?) ?: "" - manifestPlaceholders["OCULUS_APP_ID"] = appId + manifestPlaceholders["HORIZON_APP_ID"] = appId } // Amazon flavor - Amazon Appstore SDK IAP diff --git a/packages/google/Example/src/main/AndroidManifest.xml b/packages/google/Example/src/main/AndroidManifest.xml index 8ac3a162c..96fd8c848 100644 --- a/packages/google/Example/src/main/AndroidManifest.xml +++ b/packages/google/Example/src/main/AndroidManifest.xml @@ -12,11 +12,8 @@ - + android:name="com.meta.horizon.platform.HORIZON_APP_ID" + android:value="${HORIZON_APP_ID}" /> - 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 -> + val purchases = try { + withContext(Dispatchers.IO) { + ensureRegistered() + val androidArgs = props.toAndroidPurchaseArgs() + if (androidArgs.skus.isEmpty()) { + emitPurchaseErrorAndThrow(OpenIapError.EmptySkuList) + } + if (androidArgs.skus.size != 1) { emitPurchaseErrorAndThrow( - error.toOpenIapError("Amazon purchase request failed") + OpenIapError.DeveloperError("Amazon Appstore SDK purchases one SKU at a time") ) } - when (response.requestStatus) { - PurchaseResponse.RequestStatus.SUCCESSFUL -> { - val receipt = response.receipt ?: run { + + val sku = androidArgs.skus.first() + val response = runCatching { requestAmazonPurchase(sku) } + .getOrElse { error -> emitPurchaseErrorAndThrow( - OpenIapError.PurchaseFailed("Amazon purchase response did not include a receipt") + error.toOpenIapError("Amazon purchase request failed") ) } - 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 + 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) } - // 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) } + 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) } - 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) } } + } catch (_: OpenIapError) { + // The error was already published by emitPurchaseErrorAndThrow. + // Keep the request event-based, matching the Play implementation. + emptyList() } RequestPurchaseResultPurchases(purchases) } @@ -540,6 +546,9 @@ class OpenIapModule( fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, getAvailablePurchases = getAvailablePurchases, + getBillingChoiceInfoAndroid = { params -> + getBillingChoiceInfo(params) + }, getStorefront = { getStorefront() }, getStorefrontIOS = { getStorefront() }, hasActiveSubscriptions = hasActiveSubscriptions @@ -551,8 +560,8 @@ class OpenIapModule( checkAlternativeBillingAvailabilityAndroid = { checkAlternativeBillingAvailability() }, consumePurchaseAndroid = consumePurchaseAndroid, createAlternativeBillingTokenAndroid = { createAlternativeBillingReportingToken() }, - createBillingProgramReportingDetailsAndroid = { program -> - createBillingProgramReportingDetails(program) + createBillingProgramReportingDetailsAndroid = { program, developerBillingType -> + createBillingProgramReportingDetails(program, developerBillingType) }, deepLinkToSubscriptions = deepLinkToSubscriptions, endConnection = endConnection, @@ -571,6 +580,16 @@ class OpenIapModule( ?: throw OpenIapError.MissingCurrentActivity showAlternativeBillingInformationDialog(activity) }, + showBillingProgramInformationDialogAndroid = { params -> + val activity = currentActivityRef?.get() + ?: throw OpenIapError.MissingCurrentActivity + showBillingProgramInformationDialog(activity, params) + }, + showInAppMessagesAndroid = { params -> + val activity = currentActivityRef?.get() + ?: throw OpenIapError.MissingCurrentActivity + showInAppMessages(activity, params) + }, validateReceipt = validateReceipt, verifyPurchase = verifyPurchase, verifyPurchaseWithProvider = verifyPurchaseWithProvider @@ -641,7 +660,8 @@ class OpenIapModule( ) override suspend fun createBillingProgramReportingDetails( - program: BillingProgramAndroid + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? ): BillingProgramReportingDetailsAndroid { throw OpenIapError.FeatureNotSupported("Amazon Appstore does not support Google Play billing programs") } @@ -651,6 +671,24 @@ class OpenIapModule( params: LaunchExternalLinkParamsAndroid ): Boolean = false + override suspend fun getBillingChoiceInfo(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid { + throw OpenIapError.FeatureNotSupported("Amazon Appstore does not support Google Play Billing Choice") + } + + override suspend fun showBillingProgramInformationDialog( + activity: Activity, + params: BillingProgramInformationDialogParamsAndroid + ): BillingResultAndroid { + throw OpenIapError.FeatureNotSupported("Amazon Appstore does not support Google Play Billing Choice") + } + + override suspend fun showInAppMessages( + activity: Activity, + params: InAppMessageParamsAndroid? + ): InAppMessageResultAndroid { + throw OpenIapError.FeatureNotSupported("Amazon Appstore does not support Google Play billing in-app messages") + } + override fun onUserDataResponse(userDataResponse: UserDataResponse) { updateStorefront(userDataResponse.userData) completeOrCache( 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 fa98f2206..8e8a7cfb6 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 @@ -2,6 +2,7 @@ package dev.hyo.openiap import android.app.Activity import android.content.Context +import android.os.Bundle import com.meta.horizon.billingclient.api.AcknowledgePurchaseParams import com.meta.horizon.billingclient.api.AlternativeBillingOnlyInformationDialogListener import com.meta.horizon.billingclient.api.AlternativeBillingOnlyReportingDetails @@ -52,6 +53,18 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference private const val TAG = "OpenIapModule" +private const val HORIZON_APP_ID_META_DATA = "com.meta.horizon.platform.HORIZON_APP_ID" +private val LEGACY_HORIZON_APP_ID_META_DATA = listOf( + "com.meta.horizon.platform.ovr.OCULUS_APP_ID", + "com.meta.horizon.platform.ovr.HORIZON_APP_ID", + "com.oculus.vr.APP_ID" +) + +internal fun resolveHorizonAppId(metaData: Bundle?): String? = + (listOf(HORIZON_APP_ID_META_DATA) + LEGACY_HORIZON_APP_ID_META_DATA) + .firstNotNullOfOrNull { key -> + metaData?.getString(key)?.takeIf { it.isNotBlank() } + } /** * OpenIapModule for Meta Horizon Billing @@ -60,8 +73,9 @@ private const val TAG = "OpenIapModule" * @param alternativeBillingMode Alternative billing mode (default: NONE) * @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". + * The Horizon App ID is read from AndroidManifest.xml meta-data. New apps should + * use "com.meta.horizon.platform.HORIZON_APP_ID"; legacy keys remain readable + * for migration compatibility. */ class OpenIapModule( private val context: Context, @@ -79,20 +93,18 @@ class OpenIapModule( private const val PURCHASE_QUERY_DELAY_MS = 500L } - // Read Oculus App ID from AndroidManifest.xml. + // Read the canonical Horizon 2.x key first, then migration-only legacy keys. private val appId: String? by lazy { try { val appInfo = context.packageManager.getApplicationInfo( context.packageName, android.content.pm.PackageManager.GET_META_DATA ) - 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) + val id = resolveHorizonAppId(appInfo.metaData) + OpenIapLog.d("Read Horizon App ID from manifest: $id", TAG) id } catch (e: Exception) { - OpenIapLog.w("Failed to read Oculus App ID from AndroidManifest.xml: ${e.message}", TAG) + OpenIapLog.w("Failed to read Horizon App ID from AndroidManifest.xml: ${e.message}", TAG) null } } @@ -769,6 +781,9 @@ class OpenIapModule( fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, getAvailablePurchases = getAvailablePurchases, + getBillingChoiceInfoAndroid = { params -> + getBillingChoiceInfo(params) + }, getStorefront = { getStorefront() }, getStorefrontIOS = { getStorefront() }, hasActiveSubscriptions = hasActiveSubscriptions @@ -784,8 +799,8 @@ class OpenIapModule( createAlternativeBillingTokenAndroid = { createAlternativeBillingReportingToken() }, - createBillingProgramReportingDetailsAndroid = { program -> - createBillingProgramReportingDetails(program) + createBillingProgramReportingDetailsAndroid = { program, developerBillingType -> + createBillingProgramReportingDetails(program, developerBillingType) }, deepLinkToSubscriptions = deepLinkToSubscriptions, endConnection = endConnection, @@ -796,7 +811,8 @@ class OpenIapModule( }, launchExternalLinkAndroid = { params -> val activity = currentActivityRef?.get() ?: fallbackActivity - if (activity == null) false else launchExternalLink(activity, params) + ?: throw OpenIapError.MissingCurrentActivity + launchExternalLink(activity, params) }, requestPurchase = requestPurchase, restorePurchases = restorePurchases, @@ -804,6 +820,16 @@ class OpenIapModule( val activity = currentActivityRef?.get() ?: fallbackActivity if (activity == null) false else showAlternativeBillingInformationDialog(activity) }, + showBillingProgramInformationDialogAndroid = { params -> + val activity = currentActivityRef?.get() ?: fallbackActivity + ?: throw OpenIapError.MissingCurrentActivity + showBillingProgramInformationDialog(activity, params) + }, + showInAppMessagesAndroid = { params -> + val activity = currentActivityRef?.get() ?: fallbackActivity + ?: throw OpenIapError.MissingCurrentActivity + showInAppMessages(activity, params) + }, validateReceipt = validateReceipt, verifyPurchase = verifyPurchase, verifyPurchaseWithProvider = verifyPurchaseWithProvider @@ -1116,7 +1142,7 @@ class OpenIapModule( } override fun setDeveloperProvidedBillingListener(listener: dev.hyo.openiap.listener.DeveloperProvidedBillingListener?) { - // No-op: External Payments is a Google Play 8.3.0+ feature, not supported on Meta Horizon + // No-op: developer-provided billing programs are Google Play-only. OpenIapLog.w("setDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)", TAG) } @@ -1131,12 +1157,12 @@ class OpenIapModule( } override fun addDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) { - // No-op: External Payments is a Google Play 8.3.0+ feature, not supported on Meta Horizon + // No-op: developer-provided billing programs are Google Play-only. OpenIapLog.w("addDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)", TAG) } override fun removeDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) { - // No-op: External Payments is a Google Play 8.3.0+ feature, not supported on Meta Horizon + // No-op: developer-provided billing programs are Google Play-only. OpenIapLog.w("removeDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)", TAG) } @@ -1152,7 +1178,7 @@ class OpenIapModule( OpenIapLog.w("removeSubscriptionBillingIssueListener is not supported on Meta Horizon (no-op)", TAG) } - // Billing Programs (8.2.0+, EXTERNAL_PAYMENTS 8.3.0+) - Not supported on Horizon + // Google Play billing programs are not supported on Horizon. override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid { // No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon OpenIapLog.w("isBillingProgramAvailable is not supported on Meta Horizon (no-op)", TAG) @@ -1162,12 +1188,13 @@ class OpenIapModule( ) } - override suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid { - // No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon - OpenIapLog.w("createBillingProgramReportingDetails is not supported on Meta Horizon (no-op)", TAG) - return BillingProgramReportingDetailsAndroid( - billingProgram = program, - externalTransactionToken = "" + override suspend fun createBillingProgramReportingDetails( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? + ): BillingProgramReportingDetailsAndroid { + OpenIapLog.w("createBillingProgramReportingDetails is not supported on Meta Horizon", TAG) + throw OpenIapError.FeatureNotSupported( + "Meta Horizon does not support Google Play Billing Program reporting details" ) } @@ -1176,4 +1203,25 @@ class OpenIapModule( OpenIapLog.w("launchExternalLink is not supported on Meta Horizon (no-op)", TAG) return false } + + override suspend fun getBillingChoiceInfo(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid { + OpenIapLog.w("getBillingChoiceInfo is not supported on Meta Horizon", TAG) + throw OpenIapError.FeatureNotSupported("Meta Horizon does not support Google Play Billing Choice") + } + + override suspend fun showBillingProgramInformationDialog( + activity: Activity, + params: BillingProgramInformationDialogParamsAndroid + ): BillingResultAndroid { + OpenIapLog.w("showBillingProgramInformationDialog is not supported on Meta Horizon", TAG) + throw OpenIapError.FeatureNotSupported("Meta Horizon does not support Google Play Billing Choice") + } + + override suspend fun showInAppMessages( + activity: Activity, + params: InAppMessageParamsAndroid? + ): InAppMessageResultAndroid { + OpenIapLog.w("showInAppMessages is not supported on Meta Horizon", TAG) + throw OpenIapError.FeatureNotSupported("Meta Horizon does not support Google Play billing in-app messages") + } } diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt index 13cd28241..e67c3e07c 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt @@ -10,8 +10,9 @@ import dev.hyo.openiap.listener.UserChoiceBillingListener * Horizon-specific extensions for OpenIapStore * These constructors are only available in the Horizon flavor * - * Note: Oculus App ID is automatically read from AndroidManifest.xml meta-data - * with key "com.oculus.vr.APP_ID". Make sure it's properly configured via expo-iap plugin. + * The Horizon App ID is automatically read from AndroidManifest.xml meta-data + * using "com.meta.horizon.platform.HORIZON_APP_ID". Expo apps can configure it + * through the expo-iap config plugin. */ /** diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt index ba0d7dea5..88cf15cf7 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt @@ -56,15 +56,15 @@ interface OpenIapProtocol { // Developer Provided Billing (Google Play Billing Library 8.3.0+) /** - * Set a legacy-style developer-provided billing listener for External Payments (8.3.0+ Japan only). - * This is called when user selects developer billing in the side-by-side choice dialog. + * Set a legacy-style listener for External Payments (8.3.0+) and + * Google-rendered Billing Choice (9.1.0+). * * @param listener Developer-provided billing listener or null to remove */ fun setDeveloperProvidedBillingListener(listener: DeveloperProvidedBillingListener?) /** * Add listener for developer-provided billing selection events. - * Called when user selects the developer's billing option in external payments flow. + * Called when the user selects the developer's option in an enabled billing program. */ fun addDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) fun removeDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) @@ -86,7 +86,7 @@ interface OpenIapProtocol { * Check if a billing program is available for this user/device. * Replaces checkAlternativeBillingAvailability() for external offers. * - * @param program The billing program to check (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @param program The billing program to check, including BILLING_CHOICE on 9.1.0+ * @return Result containing availability information */ suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid @@ -95,10 +95,13 @@ interface OpenIapProtocol { * Create reporting details for transactions made outside of Google Play Billing. * Replaces createAlternativeBillingReportingToken() for external offers. * - * @param program The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @param program The billing program, including BILLING_CHOICE on 9.1.0+ * @return Reporting details containing the external transaction token */ - suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid + suspend fun createBillingProgramReportingDetails( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? = null + ): BillingProgramReportingDetailsAndroid /** * Launch an external link for external offer or app download. @@ -109,4 +112,26 @@ interface OpenIapProtocol { * @return true if launch was successful, false otherwise */ suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean + + /** + * Fetch Play Billing choice display assets for developer-rendered Billing Choice. + */ + suspend fun getBillingChoiceInfo(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid + + /** + * Show the mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen. + */ + suspend fun showBillingProgramInformationDialog( + activity: Activity, + params: BillingProgramInformationDialogParamsAndroid + ): BillingResultAndroid + + /** + * Show Play billing in-app messages such as payment issues or price-change confirmations. + */ + suspend fun showInAppMessages( + activity: Activity, + params: InAppMessageParamsAndroid? = null + ): InAppMessageResultAndroid } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt index a96c1e18e..5a5454859 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt @@ -53,7 +53,7 @@ class OpenIapViewModel(app: Application) : AndroidViewModel(app) { ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Purchase( - RequestPurchasePropsByPlatforms(android = android) + RequestPurchasePropsByPlatforms(google = android) ), type = type ) @@ -70,7 +70,7 @@ class OpenIapViewModel(app: Application) : AndroidViewModel(app) { ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Subscription( - RequestSubscriptionPropsByPlatforms(android = android) + RequestSubscriptionPropsByPlatforms(google = android) ), type = type ) 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 b653e26d8..b87944ae0 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 @@ -49,8 +49,76 @@ public enum class AlternativeBillingModeAndroid(val rawValue: String) { } /** - * Billing program types for external content links, external offers, and external payments (Android) - * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 + * Play Billing choice image layout (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class BillingChoiceImageLayoutAndroid(val rawValue: String) { + /** + * Rectangular image with a 4:1 aspect ratio. + */ + RectangularFourByOne("rectangular-four-by-one"), + /** + * Rectangular image with a 3:1 aspect ratio. + */ + RectangularThreeByOne("rectangular-three-by-one"), + /** + * Rectangular image with a 2:2 aspect ratio. + */ + RectangularTwoByTwo("rectangular-two-by-two"); + + companion object { + fun fromJson(value: String): BillingChoiceImageLayoutAndroid = when (value) { + "rectangular-four-by-one" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne + "RectangularFourByOne" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne + "rectangular-three-by-one" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne + "RectangularThreeByOne" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne + "rectangular-two-by-two" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo + "RectangularTwoByTwo" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo + else -> throw IllegalArgumentException("Unknown BillingChoiceImageLayoutAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Choice screen renderer for Billing Choice availability (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class BillingChoiceScreenTypeAndroid(val rawValue: String) { + /** + * Unspecified choice screen type. + */ + Unspecified("unspecified"), + /** + * Choice screen is rendered by the developer app. + */ + DeveloperRendered("developer-rendered"), + /** + * Choice screen is rendered by Google Play. + */ + GoogleRendered("google-rendered"); + + companion object { + fun fromJson(value: String): BillingChoiceScreenTypeAndroid = when (value) { + "unspecified" -> BillingChoiceScreenTypeAndroid.Unspecified + "Unspecified" -> BillingChoiceScreenTypeAndroid.Unspecified + "developer-rendered" -> BillingChoiceScreenTypeAndroid.DeveloperRendered + "DeveloperRendered" -> BillingChoiceScreenTypeAndroid.DeveloperRendered + "google-rendered" -> BillingChoiceScreenTypeAndroid.GoogleRendered + "GoogleRendered" -> BillingChoiceScreenTypeAndroid.GoogleRendered + else -> throw IllegalArgumentException("Unknown BillingChoiceScreenTypeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Billing program types for Google Play Billing Programs (Android) + * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, + * BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ public enum class BillingProgramAndroid(val rawValue: String) { /** @@ -81,7 +149,13 @@ public enum class BillingProgramAndroid(val rawValue: String) { * Users can choose to complete the purchase on the developer's website. * Available in Google Play Billing Library 8.3.0+ */ - ExternalPayments("external-payments"); + ExternalPayments("external-payments"), + /** + * Billing Choice program. + * Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + BillingChoice("billing-choice"); companion object { fun fromJson(value: String): BillingProgramAndroid = when (value) { @@ -95,6 +169,8 @@ public enum class BillingProgramAndroid(val rawValue: String) { "ExternalOffer" -> BillingProgramAndroid.ExternalOffer "external-payments" -> BillingProgramAndroid.ExternalPayments "ExternalPayments" -> BillingProgramAndroid.ExternalPayments + "billing-choice" -> BillingProgramAndroid.BillingChoice + "BillingChoice" -> BillingProgramAndroid.BillingChoice else -> throw IllegalArgumentException("Unknown BillingProgramAndroid value: $value") } } @@ -138,6 +214,39 @@ public enum class DeveloperBillingLaunchModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Developer-provided billing destination type for Billing Program reporting details (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class DeveloperBillingTypeAndroid(val rawValue: String) { + /** + * Unspecified developer billing type. Do not use. + */ + DeveloperBillingTypeUnspecified("developer-billing-type-unspecified"), + /** + * Developer-provided billing via native in-app experience. + */ + InApp("in-app"), + /** + * Developer-provided billing via external link or embedded web browsing. + */ + ExternalLink("external-link"); + + companion object { + fun fromJson(value: String): DeveloperBillingTypeAndroid = when (value) { + "developer-billing-type-unspecified" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified + "DeveloperBillingTypeUnspecified" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified + "in-app" -> DeveloperBillingTypeAndroid.InApp + "InApp" -> DeveloperBillingTypeAndroid.InApp + "external-link" -> DeveloperBillingTypeAndroid.ExternalLink + "ExternalLink" -> DeveloperBillingTypeAndroid.ExternalLink + else -> throw IllegalArgumentException("Unknown DeveloperBillingTypeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + /** * Discount offer type enumeration. * Categorizes the type of discount or promotional offer. @@ -452,8 +561,9 @@ public enum class IapEvent(val rawValue: String) { PromotedProductIos("promoted-product-ios"), UserChoiceBillingAndroid("user-choice-billing-android"), /** - * Fired when user selects developer-provided billing option in external payments flow. - * Available on Android with Google Play Billing Library 8.3.0+ + * Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + * developer billing selections on Android. Billing Choice is available in + * OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ DeveloperProvidedBillingAndroid("developer-provided-billing-android"), /** @@ -598,6 +708,62 @@ public enum class IapStore(val rawValue: String) { fun toJson(): String = rawValue } +/** + * High-level in-app message category (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public enum class InAppMessageCategoryAndroid(val rawValue: String) { + /** + * Unknown in-app message category. + */ + UnknownInAppMessageCategoryId("unknown-in-app-message-category-id"), + /** + * Transactional billing messages, such as payment issues or pending price-change confirmations. + */ + Transactional("transactional"); + + companion object { + fun fromJson(value: String): InAppMessageCategoryAndroid = when (value) { + "unknown-in-app-message-category-id" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId + "UnknownInAppMessageCategoryId" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId + "transactional" -> InAppMessageCategoryAndroid.Transactional + "Transactional" -> InAppMessageCategoryAndroid.Transactional + else -> throw IllegalArgumentException("Unknown InAppMessageCategoryAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Response code from Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public enum class InAppMessageResponseCodeAndroid(val rawValue: String) { + /** + * Flow finished and no developer action is needed. + */ + NoActionNeeded("no-action-needed"), + /** + * Subscription status changed and the purchase token should be checked. + */ + SubscriptionStatusUpdated("subscription-status-updated"); + + companion object { + fun fromJson(value: String): InAppMessageResponseCodeAndroid = when (value) { + "no-action-needed" -> InAppMessageResponseCodeAndroid.NoActionNeeded + "NoActionNeeded" -> InAppMessageResponseCodeAndroid.NoActionNeeded + "subscription-status-updated" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + "SubscriptionStatusUpdated" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + else -> throw IllegalArgumentException("Unknown InAppMessageResponseCodeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + /** * Payment mode for subscription offers. * Determines how the user pays during the offer period. @@ -1291,6 +1457,9 @@ public interface PurchaseCommon { * Store where purchase was made */ val store: IapStore + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ val transactionDate: Double } @@ -1321,6 +1490,9 @@ public data class ActiveSubscription( * pending upgrades/downgrades, and auto-renewal preferences. */ val renewalInfoIOS: RenewalInfoIOS? = null, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ val transactionDate: Double, val transactionId: String, /** @@ -1579,6 +1751,37 @@ public data class AppTransaction( ) } +/** + * Display information for developer-rendered Billing Choice screens (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class BillingChoiceInfoAndroid( + /** + * URL for the Play Billing choice image matching the requested layout. + */ + val playBillingChoiceImageUrl: String, + /** + * Play Loyalty information for the user. + */ + val playBillingLoyaltyInfo: String? = null +) { + + companion object { + fun fromJson(json: Map): BillingChoiceInfoAndroid { + return BillingChoiceInfoAndroid( + playBillingChoiceImageUrl = json["playBillingChoiceImageUrl"] as? String ?: "", + playBillingLoyaltyInfo = json["playBillingLoyaltyInfo"] as? String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "BillingChoiceInfoAndroid", + "playBillingChoiceImageUrl" to playBillingChoiceImageUrl, + "playBillingLoyaltyInfo" to playBillingLoyaltyInfo, + ) +} + /** * Result of checking billing program availability (Android) * Available in Google Play Billing Library 8.2.0+ @@ -1588,17 +1791,30 @@ public data class BillingProgramAvailabilityResultAndroid( * The billing program that was checked */ val billingProgram: BillingProgramAndroid, + /** + * Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + val choiceScreenType: BillingChoiceScreenTypeAndroid? = null, /** * Whether the billing program is available for the user */ - val isAvailable: Boolean + val isAvailable: Boolean, + /** + * Whether external-link payment is available for Billing Choice. + * Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + val isExternalLinkAvailable: Boolean? = null ) { companion object { fun fromJson(json: Map): BillingProgramAvailabilityResultAndroid { return BillingProgramAvailabilityResultAndroid( billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified, + choiceScreenType = (json["choiceScreenType"] as? String)?.let { BillingChoiceScreenTypeAndroid.fromJson(it) }, isAvailable = json["isAvailable"] as? Boolean ?: false, + isExternalLinkAvailable = json["isExternalLinkAvailable"] as? Boolean, ) } } @@ -1606,7 +1822,9 @@ public data class BillingProgramAvailabilityResultAndroid( fun toJson(): Map = mapOf( "__typename" to "BillingProgramAvailabilityResultAndroid", "billingProgram" to billingProgram.toJson(), + "choiceScreenType" to choiceScreenType?.toJson(), "isAvailable" to isAvailable, + "isExternalLinkAvailable" to isExternalLinkAvailable, ) } @@ -1689,16 +1907,32 @@ public data class BillingResultAndroid( public data class DeveloperProvidedBillingDetailsAndroid( /** * External transaction token used to report transactions made through developer billing. - * This token must be used when reporting the external transaction to Google Play. - * Must be reported within 24 hours of the transaction. + * Nullable for flows such as external payments where no token is returned. */ - val externalTransactionToken: String + val externalTransactionToken: String? = null, + /** + * URI to launch for an external-link Billing Choice flow, when provided by + * Google Play. + */ + val linkUri: String? = null, + /** + * Original external transaction ID when replacing a subscription that was + * purchased through developer billing. + */ + val originalExternalTransactionId: String? = null, + /** + * Products selected for the developer billing flow. + */ + val products: List ) { companion object { fun fromJson(json: Map): DeveloperProvidedBillingDetailsAndroid { return DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken = json["externalTransactionToken"] as? String ?: "", + externalTransactionToken = json["externalTransactionToken"] as? String, + linkUri = json["linkUri"] as? String, + originalExternalTransactionId = json["originalExternalTransactionId"] as? String, + products = (json["products"] as? List<*>)?.mapNotNull { (it as? Map)?.let { DeveloperProvidedBillingProductAndroid.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for DeveloperProvidedBillingProductAndroid") } ?: emptyList(), ) } } @@ -1706,6 +1940,45 @@ public data class DeveloperProvidedBillingDetailsAndroid( fun toJson(): Map = mapOf( "__typename" to "DeveloperProvidedBillingDetailsAndroid", "externalTransactionToken" to externalTransactionToken, + "linkUri" to linkUri, + "originalExternalTransactionId" to originalExternalTransactionId, + "products" to products.map { it.toJson() }, + ) +} + +/** + * Product selected for developer-provided billing (Android 9.0+). + */ +public data class DeveloperProvidedBillingProductAndroid( + /** + * Product identifier. + */ + val id: String, + /** + * Subscription offer token, when applicable. + */ + val offerToken: String? = null, + /** + * Google Play product type (in-app or subscription). + */ + val type: ProductType +) { + + companion object { + fun fromJson(json: Map): DeveloperProvidedBillingProductAndroid { + return DeveloperProvidedBillingProductAndroid( + id = json["id"] as? String ?: "", + offerToken = json["offerToken"] as? String, + type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "DeveloperProvidedBillingProductAndroid", + "id" to id, + "offerToken" to offerToken, + "type" to type.toJson(), ) } @@ -2213,6 +2486,38 @@ public data class FetchProductsResultProducts(val value: List?) : Fetch public data class FetchProductsResultSubscriptions(val value: List?) : FetchProductsResult +/** + * Result from showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public data class InAppMessageResultAndroid( + /** + * Purchase token returned when a subscription status changed. + */ + val purchaseToken: String? = null, + /** + * Response code for the in-app messaging flow. + */ + val responseCode: InAppMessageResponseCodeAndroid +) { + + companion object { + fun fromJson(json: Map): InAppMessageResultAndroid { + return InAppMessageResultAndroid( + purchaseToken = json["purchaseToken"] as? String, + responseCode = (json["responseCode"] as? String)?.let { InAppMessageResponseCodeAndroid.fromJson(it) } ?: InAppMessageResponseCodeAndroid.NoActionNeeded, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "InAppMessageResultAndroid", + "purchaseToken" to purchaseToken, + "responseCode" to responseCode.toJson(), + ) +} + /** * Installment plan details for subscription offers (Android) * Contains information about the installment plan commitment. @@ -2459,7 +2764,7 @@ public data class ProductAndroid( oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, price = (json["price"] as? Number)?.toDouble(), - productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) }, + productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { runCatching { ProductStatusAndroid.fromJson(it) }.getOrNull() ?: ProductStatusAndroid.Unknown }, subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") }, subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") }, title = json["title"] as? String ?: "", @@ -2721,7 +3026,7 @@ public data class ProductSubscriptionAndroid( oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, price = (json["price"] as? Number)?.toDouble(), - productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) }, + productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { runCatching { ProductStatusAndroid.fromJson(it) }.getOrNull() ?: ProductStatusAndroid.Unknown }, subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") } ?: emptyList(), subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") } ?: emptyList(), title = json["title"] as? String ?: "", @@ -2942,6 +3247,9 @@ public data class PurchaseAndroid( * Store where purchase was made */ override val store: IapStore, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ override val transactionDate: Double, val transactionId: String? = null ) : PurchaseCommon, Purchase { @@ -2964,11 +3272,11 @@ public data class PurchaseAndroid( pendingPurchaseUpdateAndroid = (json["pendingPurchaseUpdateAndroid"] as? Map)?.let { PendingPurchaseUpdateAndroid.fromJson(it) }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, productId = json["productId"] as? String ?: "", - purchaseState = (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } ?: PurchaseState.Pending, + purchaseState = runCatching { (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } }.getOrNull() ?: PurchaseState.Unknown, purchaseToken = json["purchaseToken"] as? String, quantity = (json["quantity"] as? Number)?.toInt() ?: 0, signatureAndroid = json["signatureAndroid"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String, ) @@ -3016,7 +3324,7 @@ public data class PurchaseError( companion object { fun fromJson(json: Map): PurchaseError { return PurchaseError( - code = (json["code"] as? String)?.let { ErrorCode.fromJson(it) } ?: ErrorCode.Unknown, + code = runCatching { (json["code"] as? String)?.let { ErrorCode.fromJson(it) } }.getOrNull() ?: ErrorCode.Unknown, debugMessage = json["debugMessage"] as? String, isEmptyProductList = json["isEmptyProductList"] as? Boolean, message = json["message"] as? String ?: "", @@ -3089,6 +3397,9 @@ public data class PurchaseIOS( override val store: IapStore, val storefrontCountryCodeIOS: String? = null, val subscriptionGroupIdIOS: String? = null, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ override val transactionDate: Double, val transactionId: String, val transactionReasonIOS: String? = null, @@ -3101,7 +3412,7 @@ public data class PurchaseIOS( advancedCommerceInfoIOS = (json["advancedCommerceInfoIOS"] as? Map)?.let { AdvancedCommerceInfoIOS.fromJson(it) }, appAccountToken = json["appAccountToken"] as? String, appBundleIdIOS = json["appBundleIdIOS"] as? String, - billingPlanTypeIOS = (json["billingPlanTypeIOS"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) }, + billingPlanTypeIOS = (json["billingPlanTypeIOS"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown }, commitmentInfoIOS = (json["commitmentInfoIOS"] as? Map)?.let { TransactionCommitmentInfoIOS.fromJson(it) }, countryCodeIOS = json["countryCodeIOS"] as? String, currencyCodeIOS = json["currencyCodeIOS"] as? String, @@ -3119,7 +3430,7 @@ public data class PurchaseIOS( ownershipTypeIOS = json["ownershipTypeIOS"] as? String, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, productId = json["productId"] as? String ?: "", - purchaseState = (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } ?: PurchaseState.Pending, + purchaseState = runCatching { (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } }.getOrNull() ?: PurchaseState.Unknown, purchaseToken = json["purchaseToken"] as? String, quantity = (json["quantity"] as? Number)?.toInt() ?: 0, quantityIOS = (json["quantityIOS"] as? Number)?.toInt(), @@ -3128,7 +3439,7 @@ public data class PurchaseIOS( renewalInfoIOS = (json["renewalInfoIOS"] as? Map)?.let { RenewalInfoIOS.fromJson(it) }, revocationDateIOS = (json["revocationDateIOS"] as? Number)?.toDouble(), revocationReasonIOS = json["revocationReasonIOS"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, storefrontCountryCodeIOS = json["storefrontCountryCodeIOS"] as? String, subscriptionGroupIdIOS = json["subscriptionGroupIdIOS"] as? String, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, @@ -3239,7 +3550,7 @@ public data class RenewalCommitmentInfoIOS( return RenewalCommitmentInfoIOS( commitmentAutoRenewProductId = json["commitmentAutoRenewProductId"] as? String ?: "", commitmentAutoRenewStatus = json["commitmentAutoRenewStatus"] as? Boolean ?: false, - commitmentRenewalBillingPlanType = (json["commitmentRenewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } ?: SubscriptionBillingPlanTypeIOS.Unknown, + commitmentRenewalBillingPlanType = runCatching { (json["commitmentRenewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown, commitmentRenewalDate = (json["commitmentRenewalDate"] as? Number)?.toDouble() ?: 0.0, commitmentRenewalPrice = (json["commitmentRenewalPrice"] as? Number)?.toDouble() ?: 0.0, ) @@ -3325,7 +3636,7 @@ public data class RenewalInfoIOS( jsonRepresentation = json["jsonRepresentation"] as? String, pendingUpgradeProductId = json["pendingUpgradeProductId"] as? String, priceIncreaseStatus = json["priceIncreaseStatus"] as? String, - renewalBillingPlanType = (json["renewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) }, + renewalBillingPlanType = (json["renewalBillingPlanType"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown }, renewalDate = (json["renewalDate"] as? Number)?.toDouble(), renewalOfferId = json["renewalOfferId"] as? String, renewalOfferType = json["renewalOfferType"] as? String, @@ -3406,8 +3717,8 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { return RequestVerifyPurchaseWithIapkitResult( isValid = json["isValid"] as? Boolean ?: false, - state = (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } ?: IapkitPurchaseState.Entitled, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + state = runCatching { (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } }.getOrNull() ?: IapkitPurchaseState.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, ) } } @@ -3589,7 +3900,7 @@ public data class SubscriptionOffer( numberOfPeriodsIOS = (json["numberOfPeriodsIOS"] as? Number)?.toInt(), offerTagsAndroid = (json["offerTagsAndroid"] as? List<*>)?.mapNotNull { it as? String }, offerTokenAndroid = json["offerTokenAndroid"] as? String, - paymentMode = (json["paymentMode"] as? String)?.let { PaymentMode.fromJson(it) }, + paymentMode = (json["paymentMode"] as? String)?.let { runCatching { PaymentMode.fromJson(it) }.getOrNull() ?: PaymentMode.Unknown }, period = (json["period"] as? Map)?.let { SubscriptionPeriod.fromJson(it) }, periodCount = (json["periodCount"] as? Number)?.toInt(), price = (json["price"] as? Number)?.toDouble() ?: 0.0, @@ -3683,7 +3994,7 @@ public data class SubscriptionPeriod( companion object { fun fromJson(json: Map): SubscriptionPeriod { return SubscriptionPeriod( - unit = (json["unit"] as? String)?.let { SubscriptionPeriodUnit.fromJson(it) } ?: SubscriptionPeriodUnit.Day, + unit = runCatching { (json["unit"] as? String)?.let { SubscriptionPeriodUnit.fromJson(it) } }.getOrNull() ?: SubscriptionPeriodUnit.Unknown, value = (json["value"] as? Number)?.toInt() ?: 0, ) } @@ -3731,7 +4042,7 @@ public data class SubscriptionPricingTermsIOS( return SubscriptionPricingTermsIOS( billingDisplayPrice = json["billingDisplayPrice"] as? String ?: "", billingPeriod = (json["billingPeriod"] as? Map)?.let { SubscriptionPeriodValueIOS.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionPeriodValueIOS"), - billingPlanType = (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } ?: SubscriptionBillingPlanTypeIOS.Unknown, + billingPlanType = runCatching { (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown, billingPrice = (json["billingPrice"] as? Number)?.toDouble() ?: 0.0, commitmentInfo = (json["commitmentInfo"] as? Map)?.let { SubscriptionCommitmentInfoIOS.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionCommitmentInfoIOS"), subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") }, @@ -4139,7 +4450,7 @@ public data class WebhookEvent( receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, renewsAt = (json["renewsAt"] as? Number)?.toDouble(), source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, - subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + subscriptionState = (json["subscriptionState"] as? String)?.let { runCatching { SubscriptionState.fromJson(it) }.getOrNull() ?: SubscriptionState.Unknown }, type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, ) } @@ -4197,6 +4508,38 @@ public data class AndroidSubscriptionOfferInput( ) } +/** + * Parameters for showing a billing program information dialog (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class BillingProgramInformationDialogParamsAndroid( + /** + * Billing program. Currently only BILLING_CHOICE is supported. + */ + val billingProgram: BillingProgramAndroid = BillingProgramAndroid.BillingChoice, + /** + * External transaction token returned by the Billing Choice reporting-details flow. + */ + val externalTransactionToken: String +) { + companion object { + fun fromJson(json: Map): BillingProgramInformationDialogParamsAndroid? { + val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.BillingChoice + val externalTransactionToken = json["externalTransactionToken"] as? String + if (externalTransactionToken == null) return null + return BillingProgramInformationDialogParamsAndroid( + billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, + ) + } + } + + fun toJson(): Map = mapOf( + "billingProgram" to billingProgram.toJson(), + "externalTransactionToken" to externalTransactionToken, + ) +} + public data class DeepLinkOptions( /** * Android package name to target (required on Android) @@ -4223,32 +4566,42 @@ public data class DeepLinkOptions( } /** - * Parameters for developer billing option in purchase flow (Android) - * Used with BillingFlowParams to enable external payments flow - * Available in Google Play Billing Library 8.3.0+ + * Parameters for a developer billing option in a purchase flow (Android). + * Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). + * Only billingProgram is required; link fields are used when the selected program + * links outside the app. */ public data class DeveloperBillingOptionParamsAndroid( /** - * The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + * The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. */ val billingProgram: BillingProgramAndroid, /** - * The launch mode for the external payment link + * A pre-generated external transaction token for a Billing Choice external-link + * flow. Omit it when Google Play should provide the token in the callback. */ - val launchMode: DeveloperBillingLaunchModeAndroid, + val externalTransactionToken: String? = null, /** - * The URI where the external payment will be processed + * The launch mode for the external payment link. + * Required only when the selected billing program links outside the app. */ - val linkUri: String + val launchMode: DeveloperBillingLaunchModeAndroid? = null, + /** + * The URI where the external payment will be processed. + * Required only when the selected billing program links outside the app. + */ + val linkUri: String? = null ) { companion object { fun fromJson(json: Map): DeveloperBillingOptionParamsAndroid? { val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified - val launchMode = (json["launchMode"] as? String)?.let { DeveloperBillingLaunchModeAndroid.fromJson(it) } ?: DeveloperBillingLaunchModeAndroid.Unspecified + val externalTransactionToken = json["externalTransactionToken"] as? String + val launchMode = (json["launchMode"] as? String)?.let { DeveloperBillingLaunchModeAndroid.fromJson(it) } val linkUri = json["linkUri"] as? String - if (linkUri == null) return null return DeveloperBillingOptionParamsAndroid( billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, launchMode = launchMode, linkUri = linkUri, ) @@ -4257,7 +4610,8 @@ public data class DeveloperBillingOptionParamsAndroid( fun toJson(): Map = mapOf( "billingProgram" to billingProgram.toJson(), - "launchMode" to launchMode.toJson(), + "externalTransactionToken" to externalTransactionToken, + "launchMode" to launchMode?.toJson(), "linkUri" to linkUri, ) } @@ -4311,6 +4665,65 @@ public data class DiscountOfferInputIOS( ) } +/** + * Parameters for fetching Billing Choice display information (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class GetBillingChoiceInfoParamsAndroid( + /** + * Billing program. Currently only BILLING_CHOICE is supported. + */ + val billingProgram: BillingProgramAndroid = BillingProgramAndroid.BillingChoice, + /** + * Desired Play Billing choice image layout. + */ + val playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + /** + * BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + */ + val userLocale: String? = null +) { + companion object { + fun fromJson(json: Map): GetBillingChoiceInfoParamsAndroid { + return GetBillingChoiceInfoParamsAndroid( + billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout = (json["playBillingChoiceImageLayout"] as? String)?.let { BillingChoiceImageLayoutAndroid.fromJson(it) } ?: BillingChoiceImageLayoutAndroid.RectangularFourByOne, + userLocale = json["userLocale"] as? String, + ) + } + } + + fun toJson(): Map = mapOf( + "billingProgram" to billingProgram.toJson(), + "playBillingChoiceImageLayout" to playBillingChoiceImageLayout.toJson(), + "userLocale" to userLocale, + ) +} + +/** + * Parameters for showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public data class InAppMessageParamsAndroid( + /** + * In-app message categories to show. Defaults to transactional messages. + */ + val categories: List? = listOf(InAppMessageCategoryAndroid.Transactional) +) { + companion object { + fun fromJson(json: Map): InAppMessageParamsAndroid { + return InAppMessageParamsAndroid( + categories = (json["categories"] as? List<*>)?.mapNotNull { runCatching { (it as? String)?.let { InAppMessageCategoryAndroid.fromJson(it) } }.getOrNull() ?: InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId } ?: listOf(InAppMessageCategoryAndroid.Transactional), + ) + } + } + + fun toJson(): Map = mapOf( + "categories" to categories?.map { it.toJson() }, + ) +} + /** * Connection initialization configuration */ @@ -4322,6 +4735,16 @@ public data class InitConnectionConfig( * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. */ val alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = null, + /** + * Billing Choice renderer configured in Play Console. Available in OpenIAP + * Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + * can emit the selection event. DEVELOPER_RENDERED omits that listener so the + * app can render its own choice screen and use the reporting/dialog/link APIs. + * Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + * Defaults to GOOGLE_RENDERED. + */ + val billingChoiceScreenTypeAndroid: BillingChoiceScreenTypeAndroid? = BillingChoiceScreenTypeAndroid.GoogleRendered, /** * Enable a specific billing program for Android (7.0+) * When set, enables the specified billing program for external transactions. @@ -4329,6 +4752,8 @@ public data class InitConnectionConfig( * - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) * - EXTERNAL_OFFER: External offers for digital content (8.2.0+) * - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + * - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) */ val enableBillingProgramAndroid: BillingProgramAndroid? = null ) { @@ -4336,6 +4761,7 @@ public data class InitConnectionConfig( fun fromJson(json: Map): InitConnectionConfig { return InitConnectionConfig( alternativeBillingModeAndroid = (json["alternativeBillingModeAndroid"] as? String)?.let { AlternativeBillingModeAndroid.fromJson(it) }, + billingChoiceScreenTypeAndroid = (json["billingChoiceScreenTypeAndroid"] as? String)?.let { BillingChoiceScreenTypeAndroid.fromJson(it) } ?: BillingChoiceScreenTypeAndroid.GoogleRendered, enableBillingProgramAndroid = (json["enableBillingProgramAndroid"] as? String)?.let { BillingProgramAndroid.fromJson(it) }, ) } @@ -4343,20 +4769,28 @@ public data class InitConnectionConfig( fun toJson(): Map = mapOf( "alternativeBillingModeAndroid" to alternativeBillingModeAndroid?.toJson(), + "billingChoiceScreenTypeAndroid" to billingChoiceScreenTypeAndroid?.toJson(), "enableBillingProgramAndroid" to enableBillingProgramAndroid?.toJson(), ) } /** * Parameters for launching an external link (Android) - * Used with launchExternalLink to initiate external offer or app install flows + * Used with launchExternalLink to initiate external offer, app install, or + * developer-rendered Billing Choice flows * Available in Google Play Billing Library 8.2.0+ */ public data class LaunchExternalLinkParamsAndroid( /** - * The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) */ val billingProgram: BillingProgramAndroid, + /** + * External transaction token for a developer-rendered Billing Choice external-link + * flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + */ + val externalTransactionToken: String? = null, /** * The external link launch mode */ @@ -4373,12 +4807,14 @@ public data class LaunchExternalLinkParamsAndroid( companion object { fun fromJson(json: Map): LaunchExternalLinkParamsAndroid? { val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified + val externalTransactionToken = json["externalTransactionToken"] as? String val launchMode = (json["launchMode"] as? String)?.let { ExternalLinkLaunchModeAndroid.fromJson(it) } ?: ExternalLinkLaunchModeAndroid.Unspecified val linkType = (json["linkType"] as? String)?.let { ExternalLinkTypeAndroid.fromJson(it) } ?: ExternalLinkTypeAndroid.Unspecified val linkUri = json["linkUri"] as? String if (linkUri == null) return null return LaunchExternalLinkParamsAndroid( billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, launchMode = launchMode, linkType = linkType, linkUri = linkUri, @@ -4388,6 +4824,7 @@ public data class LaunchExternalLinkParamsAndroid( fun toJson(): Map = mapOf( "billingProgram" to billingProgram.toJson(), + "externalTransactionToken" to externalTransactionToken, "launchMode" to launchMode.toJson(), "linkType" to linkType.toJson(), "linkUri" to linkUri, @@ -4396,12 +4833,12 @@ public data class LaunchExternalLinkParamsAndroid( public data class ProductRequest( val skus: List, - val type: ProductQueryType? = null + val type: ProductQueryType? = ProductQueryType.InApp ) { companion object { fun fromJson(json: Map): ProductRequest? { val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String } - val type = (json["type"] as? String)?.let { ProductQueryType.fromJson(it) } + val type = (json["type"] as? String)?.let { ProductQueryType.fromJson(it) } ?: ProductQueryType.InApp if (skus == null) return null return ProductRequest( skus = skus, @@ -4511,9 +4948,9 @@ public data class PurchaseUpdatedListenerOptions( public data class RequestPurchaseAndroidProps( /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null, /** @@ -4731,9 +5168,9 @@ public data class RequestPurchasePropsByPlatforms( public data class RequestSubscriptionAndroidProps( /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null, /** @@ -4749,6 +5186,12 @@ public data class RequestSubscriptionAndroidProps( * Obfuscated profile ID */ val obfuscatedProfileId: String? = null, + /** + * Original external transaction ID for replacing a subscription that was + * purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + val originalExternalTransactionId: String? = null, /** * Purchase token for upgrades/downgrades */ @@ -4778,6 +5221,7 @@ public data class RequestSubscriptionAndroidProps( val isOfferPersonalized = json["isOfferPersonalized"] as? Boolean val obfuscatedAccountId = json["obfuscatedAccountId"] as? String val obfuscatedProfileId = json["obfuscatedProfileId"] as? String + val originalExternalTransactionId = json["originalExternalTransactionId"] as? String val purchaseToken = json["purchaseToken"] as? String val replacementMode = (json["replacementMode"] as? Number)?.toInt() val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String } @@ -4789,6 +5233,7 @@ public data class RequestSubscriptionAndroidProps( isOfferPersonalized = isOfferPersonalized, obfuscatedAccountId = obfuscatedAccountId, obfuscatedProfileId = obfuscatedProfileId, + originalExternalTransactionId = originalExternalTransactionId, purchaseToken = purchaseToken, replacementMode = replacementMode, skus = skus, @@ -4803,6 +5248,7 @@ public data class RequestSubscriptionAndroidProps( "isOfferPersonalized" to isOfferPersonalized, "obfuscatedAccountId" to obfuscatedAccountId, "obfuscatedProfileId" to obfuscatedProfileId, + "originalExternalTransactionId" to originalExternalTransactionId, "purchaseToken" to purchaseToken, "replacementMode" to replacementMode, "skus" to skus, @@ -4859,7 +5305,7 @@ public data class RequestSubscriptionIosProps( val advancedCommerceData = json["advancedCommerceData"] as? String val andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as? Boolean val appAccountToken = json["appAccountToken"] as? String - val billingPlanType = (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } + val billingPlanType = (json["billingPlanType"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown } val compactJWS = json["compactJWS"] as? String val promotionalOfferJWS = (json["promotionalOfferJWS"] as? Map)?.let { PromotionalOfferJWSInputIOS.fromJson(it) } val quantity = (json["quantity"] as? Number)?.toInt() @@ -5081,7 +5527,7 @@ public data class SubscriptionProductReplacementParamsAndroid( companion object { fun fromJson(json: Map): SubscriptionProductReplacementParamsAndroid? { val oldProductId = json["oldProductId"] as? String - val replacementMode = (json["replacementMode"] as? String)?.let { SubscriptionReplacementModeAndroid.fromJson(it) } ?: SubscriptionReplacementModeAndroid.UnknownReplacementMode + val replacementMode = runCatching { (json["replacementMode"] as? String)?.let { SubscriptionReplacementModeAndroid.fromJson(it) } }.getOrNull() ?: SubscriptionReplacementModeAndroid.UnknownReplacementMode if (oldProductId == null) return null return SubscriptionProductReplacementParamsAndroid( oldProductId = oldProductId, @@ -5408,7 +5854,6 @@ public interface MutationResolver { suspend fun beginRefundRequestIOS(sku: String): String? /** * Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -5428,7 +5873,6 @@ public interface MutationResolver { * Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. * Must be called AFTER successful payment in your payment system. * Token must be reported to Google Play backend within 24 hours. - * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -5437,12 +5881,15 @@ public interface MutationResolver { /** * Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). * Replaces the deprecated createExternalOfferReportingDetailsAsync API. - * * Returns external transaction token needed for reporting external transactions. + * developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + * native Android defaults it to IN_APP. + * The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ - suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid + suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid? = null): BillingProgramReportingDetailsAndroid /** * Open the platform's subscription management UI. * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions @@ -5466,7 +5913,6 @@ public interface MutationResolver { /** * Check whether a billing program (e.g., External Payments) is available for the current user. * Replaces the deprecated isExternalOfferAvailableAsync API. - * * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. @@ -5474,9 +5920,11 @@ public interface MutationResolver { */ suspend fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid /** - * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + * including developer-rendered Billing Choice external-link flows. + * Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). * Replaces the deprecated showExternalOfferInformationDialog API. - * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -5521,12 +5969,19 @@ public interface MutationResolver { /** * Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. * Must be called BEFORE processing payment in your payment system. - * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ suspend fun showAlternativeBillingDialogAndroid(): Boolean + /** + * Show Google's mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + */ + suspend fun showBillingProgramInformationDialogAndroid(params: BillingProgramInformationDialogParamsAndroid): BillingResultAndroid /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. @@ -5534,6 +5989,15 @@ public interface MutationResolver { * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS + /** + * Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + * Returns a response code and, when the subscription status changes, the related purchase token. + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + */ + suspend fun showInAppMessagesAndroid(params: InAppMessageParamsAndroid? = null): InAppMessageResultAndroid /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios @@ -5610,6 +6074,13 @@ public interface QueryResolver { * See: https://openiap.dev/docs/apis/get-available-purchases */ suspend fun getAvailablePurchases(options: PurchaseOptions? = null): List + /** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client is not ready. + * See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + */ + suspend fun getBillingChoiceInfoAndroid(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. @@ -5691,11 +6162,11 @@ public interface QueryResolver { */ public interface SubscriptionResolver { /** - * Fires when a user selects developer billing in the External Payments flow (Android only) - * Triggered when the user chooses to pay via the developer's external payment option - * instead of Google Play Billing in the side-by-side choice dialog. - * Contains the externalTransactionToken needed to report the transaction. - * Available in Google Play Billing Library 8.3.0+ + * Fires when a user selects developer billing in an External Payments or + * Billing Choice flow (Android only). The payload can contain an external + * transaction token, link URI, original transaction ID, and selected products. + * Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ suspend fun developerProvidedBillingAndroid(): DeveloperProvidedBillingDetailsAndroid /** @@ -5744,7 +6215,7 @@ public typealias MutationCheckAlternativeBillingAvailabilityAndroidHandler = sus public typealias MutationClearTransactionIOSHandler = suspend () -> Boolean public typealias MutationConsumePurchaseAndroidHandler = suspend (purchaseToken: String) -> Boolean public typealias MutationCreateAlternativeBillingTokenAndroidHandler = suspend () -> String? -public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = suspend (program: BillingProgramAndroid) -> BillingProgramReportingDetailsAndroid +public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = suspend (program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid?) -> BillingProgramReportingDetailsAndroid public typealias MutationDeepLinkToSubscriptionsHandler = suspend (options: DeepLinkOptions?) -> Unit public typealias MutationEndConnectionHandler = suspend () -> Boolean public typealias MutationFinishTransactionHandler = suspend (purchase: PurchaseInput, isConsumable: Boolean?) -> Unit @@ -5758,7 +6229,9 @@ public typealias MutationRequestPurchaseHandler = suspend (params: RequestPurcha public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = suspend () -> Boolean public typealias MutationRestorePurchasesHandler = suspend () -> Unit public typealias MutationShowAlternativeBillingDialogAndroidHandler = suspend () -> Boolean +public typealias MutationShowBillingProgramInformationDialogAndroidHandler = suspend (params: BillingProgramInformationDialogParamsAndroid) -> BillingResultAndroid public typealias MutationShowExternalPurchaseCustomLinkNoticeIOSHandler = suspend (noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) -> ExternalPurchaseCustomLinkNoticeResultIOS +public typealias MutationShowInAppMessagesAndroidHandler = suspend (params: InAppMessageParamsAndroid?) -> InAppMessageResultAndroid public typealias MutationShowManageSubscriptionsIOSHandler = suspend () -> List public typealias MutationSyncIOSHandler = suspend () -> Boolean public typealias MutationValidateReceiptHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResult @@ -5786,7 +6259,9 @@ public data class MutationHandlers( val requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = null, val restorePurchases: MutationRestorePurchasesHandler? = null, val showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = null, + val showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidHandler? = null, val showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? = null, + val showInAppMessagesAndroid: MutationShowInAppMessagesAndroidHandler? = null, val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null, val syncIOS: MutationSyncIOSHandler? = null, val validateReceipt: MutationValidateReceiptHandler? = null, @@ -5803,6 +6278,7 @@ public typealias QueryGetActiveSubscriptionsHandler = suspend (subscriptionIds: public typealias QueryGetAllTransactionsIOSHandler = suspend () -> List public typealias QueryGetAppTransactionIOSHandler = suspend () -> AppTransaction? public typealias QueryGetAvailablePurchasesHandler = suspend (options: PurchaseOptions?) -> List +public typealias QueryGetBillingChoiceInfoAndroidHandler = suspend (params: GetBillingChoiceInfoParamsAndroid) -> BillingChoiceInfoAndroid public typealias QueryGetExternalPurchaseCustomLinkTokenIOSHandler = suspend (tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) -> ExternalPurchaseCustomLinkTokenResultIOS public typealias QueryGetPendingTransactionsIOSHandler = suspend () -> List public typealias QueryGetPromotedProductIOSHandler = suspend () -> ProductIOS? @@ -5826,6 +6302,7 @@ public data class QueryHandlers( val getAllTransactionsIOS: QueryGetAllTransactionsIOSHandler? = null, val getAppTransactionIOS: QueryGetAppTransactionIOSHandler? = null, val getAvailablePurchases: QueryGetAvailablePurchasesHandler? = null, + val getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidHandler? = null, val getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIOSHandler? = null, val getPendingTransactionsIOS: QueryGetPendingTransactionsIOSHandler? = null, val getPromotedProductIOS: QueryGetPromotedProductIOSHandler? = null, diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/CommonHelpers.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/CommonHelpers.kt index 56c83e45d..9d0ff8188 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/CommonHelpers.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/CommonHelpers.kt @@ -1,6 +1,7 @@ package dev.hyo.openiap.helpers import dev.hyo.openiap.AndroidSubscriptionOfferInput +import dev.hyo.openiap.BillingProgramAndroid import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid import dev.hyo.openiap.ErrorCode import dev.hyo.openiap.OpenIapError @@ -85,6 +86,7 @@ internal data class AndroidPurchaseArgs( val obfuscatedProfileId: String?, val offerToken: String?, val purchaseToken: String?, + val originalExternalTransactionId: String?, val replacementMode: Int?, val subscriptionOffers: List?, val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid?, @@ -93,6 +95,25 @@ internal data class AndroidPurchaseArgs( val useAlternativeBilling: Boolean? ) +internal fun resolveLegacySubscriptionReplacementMode( + purchaseToken: String?, + originalExternalTransactionId: String?, + replacementMode: Int?, + hasProductLevelReplacementParams: Boolean = false +): Int? = if (hasProductLevelReplacementParams) null else replacementMode ?: 5.takeIf { + !purchaseToken.isNullOrBlank() && originalExternalTransactionId.isNullOrBlank() +} + +internal fun resolveBillingProgramsForConnection( + pendingPrograms: Set, + configuredProgram: BillingProgramAndroid? +): Set = buildSet { + addAll(pendingPrograms.filterNot { it == BillingProgramAndroid.Unspecified }) + configuredProgram + ?.takeUnless { it == BillingProgramAndroid.Unspecified } + ?.let(::add) +} + /** * Extension function to convert RequestPurchaseProps to AndroidPurchaseArgs. * Shared between Play and Horizon flavors. @@ -110,6 +131,7 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { obfuscatedProfileId = params.obfuscatedProfileId, offerToken = params.offerToken, purchaseToken = null, + originalExternalTransactionId = null, replacementMode = null, subscriptionOffers = null, subscriptionProductReplacementParams = null, @@ -134,6 +156,7 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { obfuscatedProfileId = params.obfuscatedProfileId, offerToken = null, purchaseToken = params.purchaseToken, + originalExternalTransactionId = params.originalExternalTransactionId, replacementMode = params.replacementMode, subscriptionOffers = params.subscriptionOffers, subscriptionProductReplacementParams = params.subscriptionProductReplacementParams, diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/DeveloperProvidedBillingListener.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/DeveloperProvidedBillingListener.kt index f25261ad9..9c24fa0ac 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/DeveloperProvidedBillingListener.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/DeveloperProvidedBillingListener.kt @@ -1,28 +1,34 @@ package dev.hyo.openiap.listener +import dev.hyo.openiap.DeveloperProvidedBillingProductAndroid + /** - * Developer-provided billing details when user selects developer billing in External Payments flow. - * Available in Google Play Billing Library 8.3.0+ (Japan only). + * Developer-provided billing details when the user selects developer billing. + * Available for External Payments (8.3.0+) and Billing Choice (9.1.0+). */ data class DeveloperProvidedBillingDetails( /** - * External transaction token to be reported to Google within 24 hours + * External transaction token, when supplied by Google Play. */ - val externalTransactionToken: String + val externalTransactionToken: String?, + /** URI for an external-link flow, when supplied by Google Play. */ + val linkUri: String? = null, + /** Original external transaction ID for a subscription replacement. */ + val originalExternalTransactionId: String? = null, + /** Products selected for developer-provided billing. */ + val products: List = emptyList() ) /** - * Listener for developer-provided billing selection in External Payments flow. + * Listener for developer-provided billing selection in an enabled billing program. * Called when user selects the developer's billing option (instead of Google Play) - * in the side-by-side choice dialog during purchase. - * - * This is only available in Japan and requires Google Play Billing Library 8.3.0+. + * in the applicable purchase flow. */ fun interface DeveloperProvidedBillingListener { /** * Called when user selects developer-provided billing * - * @param details Developer-provided billing details including external transaction token + * @param details Developer-provided billing details for the selected flow */ fun onUserSelectedDeveloperBilling(details: DeveloperProvidedBillingDetails) } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt index 9d18e556a..ec3442764 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt @@ -41,13 +41,12 @@ fun interface OpenIapUserChoiceBillingListener { /** * Listener for Developer Provided Billing selection (Android) - * Fires when user selects developer-provided billing option in the external payments flow. - * Available in Google Play Billing Library 8.3.0+ + * Fires for External Payments (8.3.0+) and Google-rendered Billing Choice (9.1.0+). */ fun interface OpenIapDeveloperProvidedBillingListener { /** * Called when user selects developer-provided billing - * @param details The developer provided billing details containing the external transaction token + * @param details The developer-provided billing details for the selected flow */ fun onDeveloperProvidedBilling(details: DeveloperProvidedBillingDetailsAndroid) } 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 ec23bfbeb..d4447147d 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 @@ -35,7 +35,14 @@ import dev.hyo.openiap.MutationInitConnectionHandler import dev.hyo.openiap.MutationEndConnectionHandler import dev.hyo.openiap.BillingProgramAndroid import dev.hyo.openiap.BillingProgramAvailabilityResultAndroid +import dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid import dev.hyo.openiap.BillingProgramReportingDetailsAndroid +import dev.hyo.openiap.BillingChoiceInfoAndroid +import dev.hyo.openiap.BillingResultAndroid +import dev.hyo.openiap.DeveloperBillingTypeAndroid +import dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid +import dev.hyo.openiap.InAppMessageParamsAndroid +import dev.hyo.openiap.InAppMessageResultAndroid import dev.hyo.openiap.LaunchExternalLinkParamsAndroid import android.app.Activity import android.content.Context @@ -184,8 +191,8 @@ class OpenIapStore(private val module: OpenIapProtocol) { } /** - * Set a developer-provided billing listener for External Payments (8.3.0+ Japan only). - * This is called when user selects developer billing in the side-by-side choice dialog. + * Set a developer-provided billing listener for External Payments (8.3.0+) + * and Google-rendered Billing Choice (9.1.0+). * * @param listener Developer-provided billing listener or null to remove */ @@ -550,8 +557,11 @@ class OpenIapStore(private val module: OpenIapProtocol) { * * @see https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ - suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid = - module.createBillingProgramReportingDetails(program) + suspend fun createBillingProgramReportingDetails( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? = null + ): BillingProgramReportingDetailsAndroid = + module.createBillingProgramReportingDetails(program, developerBillingType) /** * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). @@ -562,10 +572,41 @@ class OpenIapStore(private val module: OpenIapProtocol) { module.launchExternalLink(activity, params) /** - * Enable a billing program for external content links or external offers (8.2.0+). + * Fetch Billing Choice display assets for developer-rendered choice screens (Play Billing 9.1.0+). + * + * @see https://openiap.dev/docs/apis/android/get-billing-choice-info-android + */ + suspend fun getBillingChoiceInfo(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid = + module.getBillingChoiceInfo(params) + + /** + * Show the mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen (Play Billing 9.1.0+). + * + * @see https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + */ + suspend fun showBillingProgramInformationDialog( + activity: Activity, + params: BillingProgramInformationDialogParamsAndroid + ): BillingResultAndroid = + module.showBillingProgramInformationDialog(activity, params) + + /** + * Show Play billing in-app messages such as payment issues or price-change confirmations. + * + * @see https://openiap.dev/docs/apis/android/show-in-app-messages-android + */ + suspend fun showInAppMessages( + activity: Activity, + params: InAppMessageParamsAndroid? = null + ): InAppMessageResultAndroid = + module.showInAppMessages(activity, params) + + /** + * Enable a billing program for the next connection (8.2.0+; Billing Choice 9.1.0+). * This should be called BEFORE initConnection to configure the BillingClient. * - * @param program The billing program to enable (ExternalOffer or ExternalContentLink) + * @param program The billing program to enable */ fun enableBillingProgram(program: BillingProgramAndroid) { // Use reflection to call enableBillingProgram on the module diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 2c5411979..5595358b5 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -10,9 +10,14 @@ import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingConfig import com.android.billingclient.api.BillingConfigResponseListener import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingProgramAvailabilityDetails +import com.android.billingclient.api.BillingProgramReportingDetailsParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.ConsumeParams +import com.android.billingclient.api.GetBillingChoiceInfoParams import com.android.billingclient.api.GetBillingConfigParams +import com.android.billingclient.api.InAppMessageParams +import com.android.billingclient.api.InAppMessageResult import com.android.billingclient.api.PendingPurchasesParams import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.Purchase as BillingPurchase @@ -58,6 +63,8 @@ import dev.hyo.openiap.helpers.queryProductDetails import dev.hyo.openiap.helpers.queryProductDetailsWithStatus import dev.hyo.openiap.helpers.queryPurchases import dev.hyo.openiap.helpers.resolveBasePlanIdForOfferToken +import dev.hyo.openiap.helpers.resolveBillingProgramsForConnection +import dev.hyo.openiap.helpers.resolveLegacySubscriptionReplacementMode import dev.hyo.openiap.helpers.resumeGuard import dev.hyo.openiap.helpers.restorePurchases as restorePurchasesHelper import dev.hyo.openiap.helpers.toAndroidPurchaseArgs @@ -73,6 +80,7 @@ import dev.hyo.openiap.utils.BillingConverters.unavailableInAppProduct import dev.hyo.openiap.utils.BillingConverters.unavailableSubscriptionProduct import dev.hyo.openiap.utils.fromBillingState import dev.hyo.openiap.utils.toActiveSubscription +import dev.hyo.openiap.utils.toOpenIapBillingResult import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay import dev.hyo.openiap.utils.verifyPurchaseWithIapkit import kotlinx.coroutines.CancellationException @@ -83,6 +91,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.lang.ref.WeakReference +import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -190,14 +199,94 @@ class OpenIapModule( .setDebugMessage(message) .build() - // Billing programs enabled via enableBillingProgram (8.2.0+, EXTERNAL_PAYMENTS in 8.3.0+) + private fun billingProgramToConstant(program: BillingProgramAndroid): Int = when (program) { + BillingProgramAndroid.ExternalContentLink -> BillingClient.BillingProgram.EXTERNAL_CONTENT_LINK + BillingProgramAndroid.ExternalOffer -> BillingClient.BillingProgram.EXTERNAL_OFFER + BillingProgramAndroid.ExternalPayments -> BillingClient.BillingProgram.EXTERNAL_PAYMENTS + BillingProgramAndroid.BillingChoice -> BillingClient.BillingProgram.BILLING_CHOICE + BillingProgramAndroid.UserChoiceBilling -> + throw IllegalArgumentException("USER_CHOICE_BILLING uses AlternativeBillingMode, not BillingProgram API") + BillingProgramAndroid.Unspecified -> + throw IllegalArgumentException("Cannot use UNSPECIFIED billing program") + } + + private fun billingChoiceProgramOrDefault(program: BillingProgramAndroid): BillingProgramAndroid = + if (program == BillingProgramAndroid.Unspecified) { + BillingProgramAndroid.BillingChoice + } else { + program + } + + private fun billingChoiceImageLayoutConstant(layout: BillingChoiceImageLayoutAndroid): String = when (layout) { + BillingChoiceImageLayoutAndroid.RectangularFourByOne -> + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_FOUR_BY_ONE + BillingChoiceImageLayoutAndroid.RectangularThreeByOne -> + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_THREE_BY_ONE + BillingChoiceImageLayoutAndroid.RectangularTwoByTwo -> + GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_TWO_BY_TWO + } + + private fun developerBillingTypeConstant( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? + ): Int? = when (developerBillingType) { + DeveloperBillingTypeAndroid.InApp -> + BillingProgramReportingDetailsParams.DeveloperBillingType.IN_APP + DeveloperBillingTypeAndroid.ExternalLink -> + BillingProgramReportingDetailsParams.DeveloperBillingType.EXTERNAL_LINK + DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified, null -> + if (program == BillingProgramAndroid.BillingChoice) { + BillingProgramReportingDetailsParams.DeveloperBillingType.IN_APP + } else { + null + } + } + + private fun billingChoiceScreenTypeFromConstant(value: Int?): BillingChoiceScreenTypeAndroid? = when (value) { + BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails.ChoiceScreenType.UNSPECIFIED -> + BillingChoiceScreenTypeAndroid.Unspecified + BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails.ChoiceScreenType.DEVELOPER_RENDERED -> + BillingChoiceScreenTypeAndroid.DeveloperRendered + BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails.ChoiceScreenType.GOOGLE_RENDERED -> + BillingChoiceScreenTypeAndroid.GoogleRendered + else -> null + } + + private fun inAppMessageCategoryToConstant(category: InAppMessageCategoryAndroid): Int = when (category) { + InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId -> + InAppMessageParams.InAppMessageCategoryId.UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID + InAppMessageCategoryAndroid.Transactional -> + InAppMessageParams.InAppMessageCategoryId.TRANSACTIONAL + } + + private fun inAppMessageResponseCodeFromConstant(value: Int?): InAppMessageResponseCodeAndroid = when (value) { + InAppMessageResult.InAppMessageResponseCode.SUBSCRIPTION_STATUS_UPDATED -> + InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + else -> InAppMessageResponseCodeAndroid.NoActionNeeded + } + + // Billing programs enabled via enableBillingProgram (8.2.0+ through Billing Choice 9.1.0+) private val enabledBillingPrograms = mutableSetOf() + private val pendingBillingPrograms = mutableSetOf() + private var billingChoiceScreenType = BillingChoiceScreenTypeAndroid.GoogleRendered override val initConnection: MutationInitConnectionHandler = { config -> + enabledBillingPrograms.clear() + enabledBillingPrograms.addAll( + resolveBillingProgramsForConnection( + pendingBillingPrograms, + config?.enableBillingProgramAndroid + ) + ) + pendingBillingPrograms.clear() + alternativeBillingMode = AlternativeBillingMode.NONE + billingChoiceScreenType = config?.billingChoiceScreenTypeAndroid + ?.takeUnless { it == BillingChoiceScreenTypeAndroid.Unspecified } + ?: BillingChoiceScreenTypeAndroid.GoogleRendered + // Handle enableBillingProgramAndroid (recommended, replaces alternativeBillingModeAndroid) config?.enableBillingProgramAndroid?.let { program -> OpenIapLog.d("Setting billing program from config: $program", TAG) - enabledBillingPrograms.add(program) // Map USER_CHOICE_BILLING to AlternativeBillingMode for backward compatibility when (program) { BillingProgramAndroid.UserChoiceBilling -> { @@ -241,6 +330,8 @@ class OpenIapModule( billingClient?.endConnection() productManager.clear() billingClient = null + enabledBillingPrograms.clear() + pendingBillingPrograms.clear() // Reset subscription-billing-issue dedupe state so a fresh // initConnection() can re-emit for previously-seen tokens. // Only clear the dedupe set — listeners persist across reconnects, @@ -598,7 +689,7 @@ class OpenIapModule( * Check if a billing program is available for this user/device (8.2.0+) * This is the new API that replaces checkAlternativeBillingAvailability for external offers. * - * @param program The billing program to check (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @param program The billing program to check, including BILLING_CHOICE on 9.1.0+ * @return Result containing availability information */ override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid = withContext(Dispatchers.IO) { @@ -607,14 +698,7 @@ class OpenIapModule( OpenIapLog.d("Checking billing program availability for: $program", TAG) - // Convert our enum to BillingClient.BillingProgram constant - val billingProgramConstant = when (program) { - BillingProgramAndroid.ExternalContentLink -> 1 // EXTERNAL_CONTENT_LINK - BillingProgramAndroid.ExternalOffer -> 3 // EXTERNAL_OFFER - BillingProgramAndroid.ExternalPayments -> 4 // EXTERNAL_PAYMENTS (8.3.0+) - BillingProgramAndroid.UserChoiceBilling -> throw IllegalArgumentException("USER_CHOICE_BILLING uses AlternativeBillingMode, not BillingProgram API") - BillingProgramAndroid.Unspecified -> throw IllegalArgumentException("Cannot check availability for UNSPECIFIED program") - } + val billingProgramConstant = billingProgramToConstant(program) suspendCancellableCoroutine { continuation -> val resumer = continuation.resumeGuard() @@ -630,9 +714,32 @@ class OpenIapModule( OpenIapLog.d("Billing program availability result: ${result?.responseCode} - ${result?.debugMessage}", TAG) val isAvailable = result?.responseCode == BillingClient.BillingResponseCode.OK + val availabilityDetails = args?.getOrNull(1) + val billingChoiceDetails = if (program == BillingProgramAndroid.BillingChoice && isAvailable) { + runCatching { + availabilityDetails?.javaClass + ?.getMethod("getBillingChoiceAvailabilityDetails") + ?.invoke(availabilityDetails) + }.getOrNull() + } else { + null + } + val choiceScreenType = runCatching { + val value = billingChoiceDetails?.javaClass + ?.getMethod("getChoiceScreenType") + ?.invoke(billingChoiceDetails) as? Int + billingChoiceScreenTypeFromConstant(value) + }.getOrNull() + val isExternalLinkAvailable = runCatching { + billingChoiceDetails?.javaClass + ?.getMethod("isExternalLinkAvailable") + ?.invoke(billingChoiceDetails) as? Boolean + }.getOrNull() resumer.resume(BillingProgramAvailabilityResultAndroid( - billingProgram = program, - isAvailable = isAvailable + billingProgram = program, + isAvailable = isAvailable, + choiceScreenType = choiceScreenType, + isExternalLinkAvailable = isExternalLinkAvailable )) } null @@ -647,14 +754,14 @@ class OpenIapModule( } catch (e: NoSuchMethodException) { OpenIapLog.e("isBillingProgramAvailableAsync not found. Requires Billing Library 8.2.0+", e, TAG) resumer.resume(BillingProgramAvailabilityResultAndroid( - billingProgram = program, - isAvailable = false + billingProgram = program, + isAvailable = false )) } catch (e: Exception) { OpenIapLog.e("Failed to check billing program availability: ${e.message}", e, TAG) resumer.resume(BillingProgramAvailabilityResultAndroid( - billingProgram = program, - isAvailable = false + billingProgram = program, + isAvailable = false )) } } @@ -666,22 +773,20 @@ class OpenIapModule( * * Note: This method uses BillingProgramReportingDetailsParams which was introduced in 8.3.0. * - * @param program The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @param program The billing program, including BILLING_CHOICE on 9.1.0+ * @return Reporting details containing the external transaction token */ - override suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid = withContext(Dispatchers.IO) { + override suspend fun createBillingProgramReportingDetails( + program: BillingProgramAndroid, + developerBillingType: DeveloperBillingTypeAndroid? + ): BillingProgramReportingDetailsAndroid = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared OpenIapLog.d("Creating billing program reporting details for: $program", TAG) - val billingProgramConstant = when (program) { - BillingProgramAndroid.ExternalContentLink -> 1 - BillingProgramAndroid.ExternalOffer -> 3 - BillingProgramAndroid.ExternalPayments -> 4 // EXTERNAL_PAYMENTS (8.3.0+) - BillingProgramAndroid.UserChoiceBilling -> throw IllegalArgumentException("USER_CHOICE_BILLING uses AlternativeBillingMode, not BillingProgram API") - BillingProgramAndroid.Unspecified -> throw IllegalArgumentException("Cannot create reporting details for UNSPECIFIED program") - } + val billingProgramConstant = billingProgramToConstant(program) + val developerBillingTypeConstant = developerBillingTypeConstant(program, developerBillingType) suspendCancellableCoroutine { continuation -> val resumer = continuation.resumeGuard() @@ -739,6 +844,19 @@ class OpenIapModule( val setBillingProgramMethod = paramsBuilderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) setBillingProgramMethod.invoke(paramsBuilder, billingProgramConstant) + developerBillingTypeConstant?.let { typeConstant -> + try { + val setDeveloperBillingTypeMethod = paramsBuilderClass.getMethod( + "setDeveloperBillingType", + Int::class.javaPrimitiveType + ) + setDeveloperBillingTypeMethod.invoke(paramsBuilder, typeConstant) + } catch (e: NoSuchMethodException) { + OpenIapLog.e("setDeveloperBillingType not found. Requires Billing Library 9.1.0+", e, TAG) + throw OpenIapError.FeatureNotSupported() + } + } + // Build the params val buildMethod = paramsBuilderClass.getMethod("build") val reportingParams = buildMethod.invoke(paramsBuilder) @@ -756,6 +874,8 @@ class OpenIapModule( } catch (e: ClassNotFoundException) { OpenIapLog.e("BillingProgramReportingDetailsParams not found. Requires Billing Library 8.3.0+", e, TAG) throw OpenIapError.FeatureNotSupported() + } catch (e: OpenIapError) { + throw e } catch (e: Exception) { OpenIapLog.e("Failed to create billing program reporting details: ${e.message}", e, TAG) throw OpenIapError.PurchaseFailed(e.message ?: e.javaClass.simpleName) @@ -771,20 +891,13 @@ class OpenIapModule( * @param params Parameters for the external link * @return true if launch was successful, false otherwise */ - override suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean = withContext(Dispatchers.IO) { + override suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean = withContext(Dispatchers.Main) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared OpenIapLog.d("Launching external link: program=${params.billingProgram}, launchMode=${params.launchMode}, linkType=${params.linkType}", TAG) - // Convert enums to BillingClient constants - val billingProgramConstant = when (params.billingProgram) { - BillingProgramAndroid.ExternalContentLink -> 1 - BillingProgramAndroid.ExternalOffer -> 3 - BillingProgramAndroid.ExternalPayments -> 4 // EXTERNAL_PAYMENTS (8.3.0+) - BillingProgramAndroid.UserChoiceBilling -> throw IllegalArgumentException("USER_CHOICE_BILLING does not use external links") - BillingProgramAndroid.Unspecified -> throw IllegalArgumentException("Cannot launch with UNSPECIFIED program") - } + val billingProgramConstant = billingProgramToConstant(params.billingProgram) val launchModeConstant = when (params.launchMode) { ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp -> 1 @@ -824,6 +937,11 @@ class OpenIapModule( val setLinkUriMethod = builderClass.getMethod("setLinkUri", android.net.Uri::class.java) setLinkUriMethod.invoke(builder, android.net.Uri.parse(params.linkUri)) + params.externalTransactionToken?.takeIf { it.isNotBlank() }?.let { token -> + builderClass.getMethod("setExternalTransactionToken", String::class.java) + .invoke(builder, token) + } + // Build the params val buildMethod = builderClass.getMethod("build") val launchParams = buildMethod.invoke(builder) @@ -863,15 +981,222 @@ class OpenIapModule( } /** - * Enable a billing program for external content links or external offers (8.2.0+) + * Fetch Billing Choice display information for developer-rendered choice screens (9.1.0+). + */ + override suspend fun getBillingChoiceInfo(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid = withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + val program = billingChoiceProgramOrDefault(params.billingProgram) + val billingProgramConstant = billingProgramToConstant(program) + if (program != BillingProgramAndroid.BillingChoice) { + throw IllegalArgumentException("getBillingChoiceInfo only supports BILLING_CHOICE") + } + + suspendCancellableCoroutine { continuation -> + val resumer = continuation.resumeGuard() + try { + val paramsClass = Class.forName("com.android.billingclient.api.GetBillingChoiceInfoParams") + val builderClass = Class.forName("com.android.billingclient.api.GetBillingChoiceInfoParams\$Builder") + + val builder = paramsClass.getMethod("newBuilder").invoke(null) + builderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) + .invoke(builder, billingProgramConstant) + builderClass.getMethod("setPlayBillingChoiceImageLayout", String::class.java) + .invoke(builder, billingChoiceImageLayoutConstant(params.playBillingChoiceImageLayout)) + params.userLocale?.takeIf { it.isNotBlank() }?.let { languageTag -> + builderClass.getMethod("setUserLocale", Locale::class.java) + .invoke(builder, Locale.forLanguageTag(languageTag)) + } + + val requestParams = builderClass.getMethod("build").invoke(builder) + val listenerClass = Class.forName("com.android.billingclient.api.BillingChoiceInfoResponseListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onBillingChoiceInfoResponse") { + val result = args?.get(0) as? BillingResult + val choiceInfo = args?.getOrNull(1) + if (result?.responseCode == BillingClient.BillingResponseCode.OK && choiceInfo != null) { + val imageUrl = choiceInfo.javaClass + .getMethod("getPlayBillingChoiceImageUrl") + .invoke(choiceInfo) as? String + val loyaltyInfo = choiceInfo.javaClass + .getMethod("getPlayBillingLoyaltyInfo") + .invoke(choiceInfo) as? String + + if (imageUrl.isNullOrBlank()) { + resumer.resumeWithException(OpenIapError.PurchaseFailed("Missing Play Billing choice image URL")) + } else { + resumer.resume(BillingChoiceInfoAndroid( + playBillingChoiceImageUrl = imageUrl, + playBillingLoyaltyInfo = loyaltyInfo + )) + } + } else { + resumer.resumeWithException(OpenIapError.PurchaseFailed(result?.debugMessage)) + } + } + null + } + + client.javaClass.getMethod("getBillingChoiceInfoAsync", paramsClass, listenerClass) + .invoke(client, requestParams, listener) + } catch (e: NoSuchMethodException) { + OpenIapLog.e("getBillingChoiceInfoAsync not found. Requires Billing Library 9.1.0+", e, TAG) + resumer.resumeWithException(OpenIapError.FeatureNotSupported()) + } catch (e: ClassNotFoundException) { + OpenIapLog.e("GetBillingChoiceInfoParams not found. Requires Billing Library 9.1.0+", e, TAG) + resumer.resumeWithException(OpenIapError.FeatureNotSupported()) + } catch (e: Exception) { + OpenIapLog.e("Failed to get Billing Choice info: ${e.message}", e, TAG) + resumer.resumeWithException(OpenIapError.PurchaseFailed(e.message ?: e.javaClass.simpleName)) + } + } + } + + /** + * Show the mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen (9.1.0+). + */ + override suspend fun showBillingProgramInformationDialog( + activity: Activity, + params: BillingProgramInformationDialogParamsAndroid + ): BillingResultAndroid = withContext(Dispatchers.Main) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + val program = billingChoiceProgramOrDefault(params.billingProgram) + val billingProgramConstant = billingProgramToConstant(program) + if (program != BillingProgramAndroid.BillingChoice) { + throw IllegalArgumentException("showBillingProgramInformationDialog only supports BILLING_CHOICE") + } + + suspendCancellableCoroutine { continuation -> + val resumer = continuation.resumeGuard() + try { + val paramsClass = Class.forName("com.android.billingclient.api.BillingProgramInformationDialogParams") + val builderClass = Class.forName("com.android.billingclient.api.BillingProgramInformationDialogParams\$Builder") + val builder = paramsClass.getMethod("newBuilder").invoke(null) + + builderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) + .invoke(builder, billingProgramConstant) + builderClass.getMethod("setExternalTransactionToken", String::class.java) + .invoke(builder, params.externalTransactionToken) + + val requestParams = builderClass.getMethod("build").invoke(builder) + val listenerClass = Class.forName("com.android.billingclient.api.BillingProgramInformationDialogListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onBillingProgramInformationDialogResponse") { + val result = (args?.get(0) as? BillingResult) + ?: billingResultError("Missing Billing Program information dialog result") + resumer.resume(result.toOpenIapBillingResult()) + } + null + } + + client.javaClass.getMethod( + "showBillingProgramInformationDialog", + Activity::class.java, + paramsClass, + listenerClass + ).invoke(client, activity, requestParams, listener) + } catch (e: NoSuchMethodException) { + OpenIapLog.e("showBillingProgramInformationDialog not found. Requires Billing Library 9.1.0+", e, TAG) + resumer.resumeWithException(OpenIapError.FeatureNotSupported()) + } catch (e: ClassNotFoundException) { + OpenIapLog.e("BillingProgramInformationDialogParams not found. Requires Billing Library 9.1.0+", e, TAG) + resumer.resumeWithException(OpenIapError.FeatureNotSupported()) + } catch (e: Exception) { + OpenIapLog.e("Failed to show Billing Program information dialog: ${e.message}", e, TAG) + resumer.resumeWithException(OpenIapError.PurchaseFailed(e.message ?: e.javaClass.simpleName)) + } + } + } + + /** + * Show Play billing in-app messages, such as payment issues or price-change confirmations. + */ + override suspend fun showInAppMessages( + activity: Activity, + params: InAppMessageParamsAndroid? + ): InAppMessageResultAndroid = withContext(Dispatchers.Main) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + suspendCancellableCoroutine { continuation -> + val resumer = continuation.resumeGuard() + try { + val paramsClass = Class.forName("com.android.billingclient.api.InAppMessageParams") + val builderClass = Class.forName("com.android.billingclient.api.InAppMessageParams\$Builder") + val builder = paramsClass.getMethod("newBuilder").invoke(null) + val categories = params?.categories?.takeIf { it.isNotEmpty() } + ?: listOf(InAppMessageCategoryAndroid.Transactional) + + for (category in categories) { + builderClass.getMethod("addInAppMessageCategoryToShow", Int::class.javaPrimitiveType) + .invoke(builder, inAppMessageCategoryToConstant(category)) + } + + val requestParams = builderClass.getMethod("build").invoke(builder) + val listenerClass = Class.forName("com.android.billingclient.api.InAppMessageResponseListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onInAppMessageResponse") { + val result = args?.get(0) + val responseCode = runCatching { + result?.javaClass?.getMethod("getResponseCode")?.invoke(result) as? Int + }.getOrNull() + val purchaseToken = runCatching { + result?.javaClass?.getMethod("getPurchaseToken")?.invoke(result) as? String + }.getOrNull() + resumer.resume(InAppMessageResultAndroid( + responseCode = inAppMessageResponseCodeFromConstant(responseCode), + purchaseToken = purchaseToken + )) + } + null + } + + val submitResult = client.javaClass.getMethod( + "showInAppMessages", + Activity::class.java, + paramsClass, + listenerClass + ).invoke(client, activity, requestParams, listener) as? BillingResult + + if (submitResult != null && submitResult.responseCode != BillingClient.BillingResponseCode.OK) { + resumer.resumeWithException(OpenIapError.PurchaseFailed(submitResult.debugMessage)) + } + } catch (e: NoSuchMethodException) { + OpenIapLog.e("showInAppMessages not found. Requires Billing Library 4.1.0+", e, TAG) + resumer.resumeWithException(OpenIapError.FeatureNotSupported()) + } catch (e: ClassNotFoundException) { + OpenIapLog.e("InAppMessageParams not found. Requires Billing Library 4.1.0+", e, TAG) + resumer.resumeWithException(OpenIapError.FeatureNotSupported()) + } catch (e: Exception) { + OpenIapLog.e("Failed to show in-app messages: ${e.message}", e, TAG) + resumer.resumeWithException(OpenIapError.PurchaseFailed(e.message ?: e.javaClass.simpleName)) + } + } + } + + /** + * Enable a billing program for the next BillingClient connection (8.2.0+). * This should be called before initConnection to configure the BillingClient. * * @param program The billing program to enable */ fun enableBillingProgram(program: BillingProgramAndroid) { if (program != BillingProgramAndroid.Unspecified) { - enabledBillingPrograms.add(program) - OpenIapLog.d("Billing program enabled: $program", TAG) + pendingBillingPrograms.add(program) + OpenIapLog.d("Billing program queued for next connection: $program", TAG) } } @@ -915,7 +1240,7 @@ class OpenIapModule( "• Wait for Google approval\n" + "• Test with license tester accounts\n\n" + "Current mode: ALTERNATIVE_ONLY\n" + - "Library: Billing 8.1.0" + "Library: Billing 9.1.0" ) for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } @@ -1163,11 +1488,15 @@ class OpenIapModule( OpenIapLog.d("==========================================", TAG) } - // For subscription upgrades/downgrades, purchaseToken and obfuscatedProfileId are mutually exclusive - if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseToken.isNullOrBlank()) { + val hasSubscriptionUpdateSource = + !androidArgs.purchaseToken.isNullOrBlank() || + !androidArgs.originalExternalTransactionId.isNullOrBlank() + + // Subscription replacements identify the original purchase with either a + // Play purchase token or a developer-billing transaction ID. + if (androidArgs.type == ProductQueryType.Subs && hasSubscriptionUpdateSource) { // This is a subscription upgrade/downgrade - do not set obfuscatedProfileId OpenIapLog.d("=== Subscription Upgrade Flow ===", TAG) - OpenIapLog.d(" - Old Token: ${androidArgs.purchaseToken}", TAG) OpenIapLog.d(" - Target SKUs: ${androidArgs.skus}", TAG) OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementMode}", TAG) OpenIapLog.d(" - Product Details Count: ${paramsList.size}", TAG) @@ -1176,16 +1505,26 @@ class OpenIapModule( } val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder() - .setOldPurchaseToken(androidArgs.purchaseToken) + androidArgs.purchaseToken?.takeIf { it.isNotBlank() }?.let { + updateParamsBuilder.setOldPurchaseToken(it) + } + androidArgs.originalExternalTransactionId?.takeIf { it.isNotBlank() }?.let { + updateParamsBuilder.setOriginalExternalTransactionId(it) + } - // Set replacement mode - this is critical for upgrades - // Note: setSubscriptionReplacementMode() is deprecated in Billing 8.1.0 - // in favor of SubscriptionProductReplacementParams for per-product control. - // However, for single-product upgrades, the legacy API still works. - val replacementMode = androidArgs.replacementMode ?: 5 // Default to CHARGE_FULL_PRICE - @Suppress("DEPRECATION") - updateParamsBuilder.setSubscriptionReplacementMode(replacementMode) - OpenIapLog.d(" - Final replacement mode: $replacementMode", TAG) + // Developer-billed replacements use only the original external + // transaction ID unless the caller explicitly supplies a mode. + val replacementMode = resolveLegacySubscriptionReplacementMode( + androidArgs.purchaseToken, + androidArgs.originalExternalTransactionId, + androidArgs.replacementMode, + androidArgs.subscriptionProductReplacementParams != null + ) + replacementMode?.let { mode -> + @Suppress("DEPRECATION") + updateParamsBuilder.setSubscriptionReplacementMode(mode) + OpenIapLog.d(" - Final replacement mode: $mode", TAG) + } val updateParams = updateParamsBuilder.build() flowBuilder.setSubscriptionUpdateParams(updateParams) @@ -1458,6 +1797,9 @@ class OpenIapModule( fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, getAvailablePurchases = getAvailablePurchases, + getBillingChoiceInfoAndroid = { params -> + getBillingChoiceInfo(params) + }, getStorefront = { getStorefront() }, getStorefrontIOS = { getStorefront() }, hasActiveSubscriptions = hasActiveSubscriptions @@ -1473,8 +1815,8 @@ class OpenIapModule( createAlternativeBillingTokenAndroid = { createAlternativeBillingReportingToken() }, - createBillingProgramReportingDetailsAndroid = { program -> - createBillingProgramReportingDetails(program) + createBillingProgramReportingDetailsAndroid = { program, developerBillingType -> + createBillingProgramReportingDetails(program, developerBillingType) }, deepLinkToSubscriptions = deepLinkToSubscriptions, endConnection = endConnection, @@ -1495,6 +1837,16 @@ class OpenIapModule( ?: throw OpenIapError.MissingCurrentActivity showAlternativeBillingInformationDialog(activity) }, + showBillingProgramInformationDialogAndroid = { params -> + val activity = currentActivityRef?.get() ?: fallbackActivity + ?: throw OpenIapError.MissingCurrentActivity + showBillingProgramInformationDialog(activity, params) + }, + showInAppMessagesAndroid = { params -> + val activity = currentActivityRef?.get() ?: fallbackActivity + ?: throw OpenIapError.MissingCurrentActivity + showInAppMessages(activity, params) + }, validateReceipt = validateReceipt, verifyPurchase = verifyPurchase, verifyPurchaseWithProvider = verifyPurchaseWithProvider @@ -1794,7 +2146,7 @@ class OpenIapModule( } catch (e: NoSuchMethodException) { OpenIapLog.e("✗ enableAlternativeBillingOnly() method not found", e, TAG) OpenIapLog.e("This method requires Billing Library 6.2+", tag = TAG) - OpenIapLog.e("Current library version: 8.1.0", tag = TAG) + OpenIapLog.e("Current library version: 9.1.0", tag = TAG) OpenIapLog.e("Alternative billing will NOT work - standard Google Play billing will be used", tag = TAG) } catch (e: Exception) { OpenIapLog.e("✗ Failed to enable alternative billing only: ${e.javaClass.simpleName}: ${e.message}", e, TAG) @@ -1803,7 +2155,7 @@ class OpenIapModule( } } - // Enable billing programs (8.2.0+, EXTERNAL_PAYMENTS in 8.3.0+) + // Enable billing programs (8.2.0+ through Billing Choice 9.1.0+) if (enabledBillingPrograms.isNotEmpty()) { OpenIapLog.d("=== BILLING PROGRAMS INITIALIZATION ===", TAG) for (program in enabledBillingPrograms) { @@ -1814,22 +2166,33 @@ class OpenIapModule( } val programConstant = when (program) { - BillingProgramAndroid.ExternalContentLink -> 1 - BillingProgramAndroid.ExternalOffer -> 3 - BillingProgramAndroid.ExternalPayments -> 4 // EXTERNAL_PAYMENTS (8.3.0+) BillingProgramAndroid.UserChoiceBilling -> continue // Already handled above BillingProgramAndroid.Unspecified -> continue + else -> billingProgramToConstant(program) } - // For EXTERNAL_PAYMENTS, we need to use EnableBillingProgramParams with DeveloperProvidedBillingListener - if (program == BillingProgramAndroid.ExternalPayments) { + val needsDeveloperListener = + program == BillingProgramAndroid.ExternalPayments || + (program == BillingProgramAndroid.BillingChoice && + billingChoiceScreenType != BillingChoiceScreenTypeAndroid.DeveloperRendered) + + if (needsDeveloperListener) { try { - enableExternalPaymentsProgram(builder) - OpenIapLog.d("✓ External Payments program enabled (8.3.0+)", TAG) + enableBillingProgramWithDeveloperListener(builder, program, programConstant) + OpenIapLog.d("✓ Billing program enabled with developer listener: $program", TAG) } catch (e: NoSuchMethodException) { - OpenIapLog.w("✗ EXTERNAL_PAYMENTS not found. Requires Billing Library 8.3.0+", TAG) + OpenIapLog.w("✗ EnableBillingProgramParams not found for $program", TAG) } catch (e: Exception) { - OpenIapLog.w("✗ Failed to enable EXTERNAL_PAYMENTS: ${e.message}", TAG) + OpenIapLog.w("✗ Failed to enable billing program $program: ${e.message}", TAG) + } + } else if (program == BillingProgramAndroid.BillingChoice) { + try { + enableBillingProgramWithoutDeveloperListener(builder, program, programConstant) + OpenIapLog.d("✓ Developer-rendered Billing Choice enabled without developer listener", TAG) + } catch (e: NoSuchMethodException) { + OpenIapLog.w("✗ EnableBillingProgramParams not found for $program", TAG) + } catch (e: Exception) { + OpenIapLog.w("✗ Failed to enable billing program $program: ${e.message}", TAG) } } else { // For other programs, use the simpler enableBillingProgram method @@ -1915,7 +2278,8 @@ class OpenIapModule( } /** - * Set legacy-style developer-provided billing listener for External Payments (8.3.0+ Japan only). + * Set the legacy-style developer-provided billing listener for External Payments + * (8.3.0+) and Google-rendered Billing Choice (9.1.0+). * @param listener Developer-provided billing listener or null to remove */ override fun setDeveloperProvidedBillingListener(listener: dev.hyo.openiap.listener.DeveloperProvidedBillingListener?) { @@ -1980,13 +2344,17 @@ class OpenIapModule( } /** - * Enable EXTERNAL_PAYMENTS billing program with DeveloperProvidedBillingListener. - * This is the new API in Billing Library 8.3.0 for external payments (Japan only). + * Enable a billing program with DeveloperProvidedBillingListener. + * Used by EXTERNAL_PAYMENTS (8.3.0+) and BILLING_CHOICE (9.1.0+). * * @param builder The BillingClient.Builder to configure */ - private fun enableExternalPaymentsProgram(builder: BillingClient.Builder) { - OpenIapLog.d("=== EXTERNAL PAYMENTS INITIALIZATION (8.3.0+) ===", TAG) + private fun enableBillingProgramWithDeveloperListener( + builder: BillingClient.Builder, + program: BillingProgramAndroid, + programConstant: Int + ) { + OpenIapLog.d("=== BILLING PROGRAM INITIALIZATION WITH DEVELOPER LISTENER: $program ===", TAG) // Create DeveloperProvidedBillingListener via reflection val listenerClass = Class.forName("com.android.billingclient.api.DeveloperProvidedBillingListener") @@ -1999,43 +2367,69 @@ class OpenIapModule( val billingDetails = args?.get(0) OpenIapLog.d("DeveloperProvidedBillingDetails: $billingDetails", TAG) - // Extract external transaction token try { val detailsClass = billingDetails?.javaClass - val tokenMethod = detailsClass?.getMethod("getExternalTransactionToken") - val externalToken = tokenMethod?.invoke(billingDetails) as? String - - if (externalToken != null) { - OpenIapLog.d("External transaction token: $externalToken", TAG) + val externalToken = (detailsClass + ?.getMethod("getExternalTransactionToken") + ?.invoke(billingDetails) as? String) + ?.takeIf { it.isNotBlank() } + val linkUri = (detailsClass + ?.getMethod("getLinkUri") + ?.invoke(billingDetails) as? String) + ?.takeIf { it.isNotBlank() } + val originalExternalTransactionId = (detailsClass + ?.getMethod("getOriginalExternalTransactionId") + ?.invoke(billingDetails) as? String) + ?.takeIf { it.isNotBlank() } + val products = (detailsClass + ?.getMethod("getProducts") + ?.invoke(billingDetails) as? List<*>) + .orEmpty() + .mapNotNull { product -> + val productClass = product?.javaClass ?: return@mapNotNull null + val id = productClass.getMethod("getId").invoke(product) as? String + ?: return@mapNotNull null + val type = when (productClass.getMethod("getType").invoke(product) as? String) { + BillingClient.ProductType.INAPP -> ProductType.InApp + BillingClient.ProductType.SUBS -> ProductType.Subs + else -> return@mapNotNull null + } + DeveloperProvidedBillingProductAndroid( + id = id, + offerToken = (productClass.getMethod("getOfferToken") + .invoke(product) as? String)?.takeIf { it.isNotBlank() }, + type = type + ) + } - // Create DeveloperProvidedBillingDetailsAndroid for the event - val details = DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken = externalToken - ) + val details = DeveloperProvidedBillingDetailsAndroid( + externalTransactionToken = externalToken, + linkUri = linkUri, + originalExternalTransactionId = originalExternalTransactionId, + products = products + ) - // Notify legacy-style listener (set via constructor or setDeveloperProvidedBillingListener) - developerProvidedBillingListener?.let { legacyListener -> - try { - legacyListener.onUserSelectedDeveloperBilling( - dev.hyo.openiap.listener.DeveloperProvidedBillingDetails( - externalTransactionToken = externalToken - ) + developerProvidedBillingListener?.let { legacyListener -> + try { + legacyListener.onUserSelectedDeveloperBilling( + dev.hyo.openiap.listener.DeveloperProvidedBillingDetails( + externalTransactionToken = externalToken, + linkUri = linkUri, + originalExternalTransactionId = originalExternalTransactionId, + products = products ) - } catch (e: Exception) { - OpenIapLog.w("Legacy DeveloperProvidedBilling listener error: ${e.message}", TAG) - } + ) + } catch (e: Exception) { + OpenIapLog.w("Legacy DeveloperProvidedBilling listener error: ${e.message}", TAG) } + } - // Notify all DeveloperProvidedBilling listeners (added via addDeveloperProvidedBillingListener) - for (listener in developerProvidedBillingListeners) { - try { - listener.onDeveloperProvidedBilling(details) - } catch (e: Exception) { - OpenIapLog.w("DeveloperProvidedBilling listener error: ${e.message}", TAG) - } + for (listener in developerProvidedBillingListeners) { + try { + listener.onDeveloperProvidedBilling(details) + } catch (e: Exception) { + OpenIapLog.w("DeveloperProvidedBilling listener error: ${e.message}", TAG) } - } else { - OpenIapLog.w("Failed to extract external transaction token", TAG) } } catch (e: Exception) { OpenIapLog.e("Error processing developer billing details", e, TAG) @@ -2052,9 +2446,8 @@ class OpenIapModule( val newBuilderMethod = enableParamsClass.getMethod("newBuilder") val enableBuilder = newBuilderMethod.invoke(null) - // Set billing program to EXTERNAL_PAYMENTS (constant = 4) val setBillingProgramMethod = enableParamsBuilderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) - setBillingProgramMethod.invoke(enableBuilder, 4) // EXTERNAL_PAYMENTS + setBillingProgramMethod.invoke(enableBuilder, programConstant) // Set developer provided billing listener val setListenerMethod = enableParamsBuilderClass.getMethod("setDeveloperProvidedBillingListener", listenerClass) @@ -2069,7 +2462,26 @@ class OpenIapModule( enableMethod.invoke(builder, enableParams) OpenIapLog.d("✓ DeveloperProvidedBillingListener registered", TAG) - OpenIapLog.d("=== END EXTERNAL PAYMENTS INITIALIZATION ===", TAG) + OpenIapLog.d("=== END BILLING PROGRAM INITIALIZATION WITH DEVELOPER LISTENER ===", TAG) + } + + /** Enable developer-rendered Billing Choice without a selection listener. */ + private fun enableBillingProgramWithoutDeveloperListener( + builder: BillingClient.Builder, + program: BillingProgramAndroid, + programConstant: Int + ) { + val enableParamsClass = Class.forName("com.android.billingclient.api.EnableBillingProgramParams") + val enableParamsBuilderClass = Class.forName("com.android.billingclient.api.EnableBillingProgramParams\$Builder") + val enableBuilder = enableParamsClass.getMethod("newBuilder").invoke(null) + + enableParamsBuilderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) + .invoke(enableBuilder, programConstant) + val enableParams = enableParamsBuilderClass.getMethod("build").invoke(enableBuilder) + builder.javaClass.getMethod("enableBillingProgram", enableParamsClass) + .invoke(builder, enableParams) + + OpenIapLog.d("Billing program enabled without developer listener: $program", TAG) } /** @@ -2086,19 +2498,13 @@ class OpenIapModule( try { OpenIapLog.d("Applying DeveloperBillingOption: program=${params.billingProgram}, launchMode=${params.launchMode}, uri=${params.linkUri}", TAG) - // Convert enums to constants - val billingProgramConstant = when (params.billingProgram) { - BillingProgramAndroid.ExternalPayments -> 4 - BillingProgramAndroid.ExternalContentLink -> 1 - BillingProgramAndroid.ExternalOffer -> 3 - BillingProgramAndroid.UserChoiceBilling -> throw IllegalArgumentException("USER_CHOICE_BILLING does not use DeveloperBillingOption") - BillingProgramAndroid.Unspecified -> throw IllegalArgumentException("Cannot use UNSPECIFIED billing program") - } + val billingProgramConstant = billingProgramToConstant(params.billingProgram) val launchModeConstant = when (params.launchMode) { DeveloperBillingLaunchModeAndroid.LaunchInExternalBrowserOrApp -> 1 DeveloperBillingLaunchModeAndroid.CallerWillLaunchLink -> 2 DeveloperBillingLaunchModeAndroid.Unspecified -> throw IllegalArgumentException("Cannot use UNSPECIFIED launch mode") + null -> null } // Build DeveloperBillingOptionParams using reflection @@ -2112,13 +2518,20 @@ class OpenIapModule( val setBillingProgramMethod = developerBillingBuilderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) setBillingProgramMethod.invoke(developerBillingBuilder, billingProgramConstant) - // Set link URI (must use android.net.Uri, not String) - val setLinkUriMethod = developerBillingBuilderClass.getMethod("setLinkUri", android.net.Uri::class.java) - setLinkUriMethod.invoke(developerBillingBuilder, android.net.Uri.parse(params.linkUri)) + params.linkUri?.takeIf { it.isNotBlank() }?.let { linkUri -> + developerBillingBuilderClass.getMethod("setLinkUri", android.net.Uri::class.java) + .invoke(developerBillingBuilder, android.net.Uri.parse(linkUri)) + } - // Set launch mode - val setLaunchModeMethod = developerBillingBuilderClass.getMethod("setLaunchMode", Int::class.javaPrimitiveType) - setLaunchModeMethod.invoke(developerBillingBuilder, launchModeConstant) + launchModeConstant?.let { launchMode -> + developerBillingBuilderClass.getMethod("setLaunchMode", Int::class.javaPrimitiveType) + .invoke(developerBillingBuilder, launchMode) + } + + params.externalTransactionToken?.takeIf { it.isNotBlank() }?.let { token -> + developerBillingBuilderClass.getMethod("setExternalTransactionToken", String::class.java) + .invoke(developerBillingBuilder, token) + } // Build the developer billing params val buildMethod = developerBillingBuilderClass.getMethod("build") @@ -2134,10 +2547,13 @@ class OpenIapModule( OpenIapLog.d("✓ DeveloperBillingOption applied successfully", TAG) } catch (e: NoSuchMethodException) { OpenIapLog.w("DeveloperBillingOption not found. Requires Billing Library 8.3.0+", TAG) + throw OpenIapError.FeatureNotSupported() } catch (e: ClassNotFoundException) { OpenIapLog.w("DeveloperBillingOptionParams class not found. Requires Billing Library 8.3.0+", TAG) + throw OpenIapError.FeatureNotSupported() } catch (e: Exception) { OpenIapLog.e("Failed to apply DeveloperBillingOption: ${e.message}", e, TAG) + throw e } } } diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingResultConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingResultConverters.kt new file mode 100644 index 000000000..058558de0 --- /dev/null +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingResultConverters.kt @@ -0,0 +1,23 @@ +package dev.hyo.openiap.utils + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import dev.hyo.openiap.BillingResultAndroid +import dev.hyo.openiap.SubResponseCodeAndroid + +internal fun Int.toOpenIapSubResponseCode(): SubResponseCodeAndroid? = when (this) { + BillingClient.OnPurchasesUpdatedSubResponseCode.NO_APPLICABLE_SUB_RESPONSE_CODE -> + SubResponseCodeAndroid.NoApplicableSubResponseCode + BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -> + SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds + BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE -> + SubResponseCodeAndroid.UserIneligible + else -> null +} + +internal fun BillingResult.toOpenIapBillingResult(): BillingResultAndroid = + BillingResultAndroid( + responseCode = responseCode, + debugMessage = debugMessage, + subResponseCode = onPurchasesUpdatedSubResponseCode.toOpenIapSubResponseCode() + ) diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingChoiceAndroidTypesTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingChoiceAndroidTypesTest.kt new file mode 100644 index 000000000..38e2c4d53 --- /dev/null +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingChoiceAndroidTypesTest.kt @@ -0,0 +1,192 @@ +package dev.hyo.openiap + +import dev.hyo.openiap.helpers.resolveBillingProgramsForConnection +import dev.hyo.openiap.helpers.resolveLegacySubscriptionReplacementMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BillingChoiceAndroidTypesTest { + @Test + fun `Billing Choice image layout serializes correctly`() { + assertEquals( + BillingChoiceImageLayoutAndroid.RectangularFourByOne, + BillingChoiceImageLayoutAndroid.fromJson("rectangular-four-by-one") + ) + assertEquals( + "rectangular-two-by-two", + BillingChoiceImageLayoutAndroid.RectangularTwoByTwo.toJson() + ) + } + + @Test + fun `Billing Choice screen type serializes correctly`() { + assertEquals( + BillingChoiceScreenTypeAndroid.DeveloperRendered, + BillingChoiceScreenTypeAndroid.fromJson("developer-rendered") + ) + assertEquals( + "google-rendered", + BillingChoiceScreenTypeAndroid.GoogleRendered.toJson() + ) + } + + @Test + fun `Billing Choice connection config defaults and serializes renderer`() { + assertEquals( + BillingChoiceScreenTypeAndroid.GoogleRendered, + InitConnectionConfig().billingChoiceScreenTypeAndroid + ) + assertEquals( + BillingChoiceScreenTypeAndroid.DeveloperRendered, + InitConnectionConfig.fromJson( + mapOf("billingChoiceScreenTypeAndroid" to "developer-rendered") + ).billingChoiceScreenTypeAndroid + ) + } + + @Test + fun `Billing Choice external link preserves reporting token`() { + val params = LaunchExternalLinkParamsAndroid.fromJson( + mapOf( + "billingProgram" to "billing-choice", + "externalTransactionToken" to "external-token", + "launchMode" to "launch-in-external-browser-or-app", + "linkType" to "link-to-digital-content-offer", + "linkUri" to "https://example.com/checkout" + ) + )!! + + assertEquals(BillingProgramAndroid.BillingChoice, params.billingProgram) + assertEquals("external-token", params.externalTransactionToken) + } + + @Test + fun `Developer billing type serializes correctly`() { + assertEquals( + DeveloperBillingTypeAndroid.InApp, + DeveloperBillingTypeAndroid.fromJson("in-app") + ) + assertEquals( + "external-link", + DeveloperBillingTypeAndroid.ExternalLink.toJson() + ) + } + + @Test + fun `in-app message enums serialize correctly`() { + assertEquals( + InAppMessageCategoryAndroid.Transactional, + InAppMessageCategoryAndroid.fromJson("transactional") + ) + assertEquals( + InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated, + InAppMessageResponseCodeAndroid.fromJson("subscription-status-updated") + ) + } + + @Test + fun `developer billing option supports in-app and external-link Billing Choice`() { + val inApp = DeveloperBillingOptionParamsAndroid.fromJson( + mapOf("billingProgram" to "billing-choice") + )!! + assertEquals(BillingProgramAndroid.BillingChoice, inApp.billingProgram) + assertNull(inApp.linkUri) + assertNull(inApp.launchMode) + assertNull(inApp.externalTransactionToken) + + val externalLink = DeveloperBillingOptionParamsAndroid.fromJson( + mapOf( + "billingProgram" to "billing-choice", + "externalTransactionToken" to "external-token", + "launchMode" to "caller-will-launch-link", + "linkUri" to "https://example.com/checkout" + ) + )!! + assertEquals("external-token", externalLink.externalTransactionToken) + assertEquals( + DeveloperBillingLaunchModeAndroid.CallerWillLaunchLink, + externalLink.launchMode + ) + } + + @Test + fun `developer provided billing details preserve nullable fields and products`() { + val details = DeveloperProvidedBillingDetailsAndroid.fromJson( + mapOf( + "externalTransactionToken" to null, + "linkUri" to "https://example.com/checkout", + "originalExternalTransactionId" to "original-external-id", + "products" to listOf( + mapOf( + "id" to "premium_monthly", + "type" to "subs", + "offerToken" to "offer-token" + ) + ) + ) + ) + + assertNull(details.externalTransactionToken) + assertEquals("https://example.com/checkout", details.linkUri) + assertEquals("original-external-id", details.originalExternalTransactionId) + assertEquals(ProductType.Subs, details.products.single().type) + assertEquals("offer-token", details.products.single().offerToken) + } + + @Test + fun `developer billed replacement does not inject a Play replacement mode`() { + assertNull( + resolveLegacySubscriptionReplacementMode( + purchaseToken = null, + originalExternalTransactionId = "original-external-id", + replacementMode = null + ) + ) + assertEquals( + 5, + resolveLegacySubscriptionReplacementMode( + purchaseToken = "play-purchase-token", + originalExternalTransactionId = null, + replacementMode = null + ) + ) + assertEquals( + 3, + resolveLegacySubscriptionReplacementMode( + purchaseToken = null, + originalExternalTransactionId = "original-external-id", + replacementMode = 3 + ) + ) + assertNull( + resolveLegacySubscriptionReplacementMode( + purchaseToken = "play-purchase-token", + originalExternalTransactionId = null, + replacementMode = 3, + hasProductLevelReplacementParams = true + ) + ) + } + + @Test + fun `pre-init billing programs survive connection config reset`() { + assertEquals( + setOf(BillingProgramAndroid.ExternalOffer), + resolveBillingProgramsForConnection( + pendingPrograms = setOf(BillingProgramAndroid.ExternalOffer), + configuredProgram = null + ) + ) + assertEquals( + setOf( + BillingProgramAndroid.ExternalOffer, + BillingProgramAndroid.BillingChoice + ), + resolveBillingProgramsForConnection( + pendingPrograms = setOf(BillingProgramAndroid.ExternalOffer), + configuredProgram = BillingProgramAndroid.BillingChoice + ) + ) + } +} diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt index 7059bda19..f51cd0a4f 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt @@ -1,9 +1,12 @@ package dev.hyo.openiap +import com.android.billingclient.api.BillingClient import org.junit.Assert.assertNotNull +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test +import java.util.Locale /** * Tests to verify that reflection-based class paths used in OpenIapModule @@ -142,6 +145,15 @@ class BillingLibraryClassPathTest { ) } + @Test + fun `SubscriptionUpdateParams Builder supports external transaction replacement`() { + assertClassHasMethod( + "com.android.billingclient.api.BillingFlowParams\$SubscriptionUpdateParams\$Builder", + "setOriginalExternalTransactionId", + String::class.java + ) + } + // ============================================================================ // MARK: - AlternativeBillingOnlyAvailabilityListener (Billing Library 6.0+) // Used in: OpenIapModule.checkAlternativeBillingAvailability() @@ -240,6 +252,26 @@ class BillingLibraryClassPathTest { ) } + @Test + fun `BillingProgramAvailabilityDetails exposes Billing Choice details`() { + assertClassHasMethod( + "com.android.billingclient.api.BillingProgramAvailabilityDetails", + "getBillingChoiceAvailabilityDetails" + ) + assertClassHasMethod( + "com.android.billingclient.api.BillingProgramAvailabilityDetails\$BillingChoiceAvailabilityDetails", + "getChoiceScreenType" + ) + assertClassHasMethod( + "com.android.billingclient.api.BillingProgramAvailabilityDetails\$BillingChoiceAvailabilityDetails", + "isExternalLinkAvailable" + ) + assertClassExists( + "com.android.billingclient.api.BillingProgramAvailabilityDetails\$BillingChoiceAvailabilityDetails\$ChoiceScreenType", + "9.1.0+" + ) + } + // ============================================================================ // MARK: - BillingProgramReportingDetailsListener (Billing Library 8.3.0+) // Used in: OpenIapModule.createBillingProgramReportingDetails() @@ -300,6 +332,23 @@ class BillingLibraryClassPathTest { ) } + @Test + fun `BillingProgramReportingDetailsParams Builder has setDeveloperBillingType method`() { + assertClassHasMethod( + "com.android.billingclient.api.BillingProgramReportingDetailsParams\$Builder", + "setDeveloperBillingType", + Int::class.javaPrimitiveType!! + ) + } + + @Test + fun `BillingProgramReportingDetailsParams DeveloperBillingType annotation exists`() { + assertClassExists( + "com.android.billingclient.api.BillingProgramReportingDetailsParams\$DeveloperBillingType", + "9.1.0+" + ) + } + @Test fun `BillingProgramReportingDetailsParams Builder has build method`() { assertClassHasMethod( @@ -373,6 +422,15 @@ class BillingLibraryClassPathTest { ) } + @Test + fun `LaunchExternalLinkParams Builder supports Billing Choice transaction token`() { + assertClassHasMethod( + "com.android.billingclient.api.LaunchExternalLinkParams\$Builder", + "setExternalTransactionToken", + String::class.java + ) + } + @Test fun `LaunchExternalLinkParams Builder has build method`() { assertClassHasMethod( @@ -403,6 +461,133 @@ class BillingLibraryClassPathTest { ) } + // ============================================================================ + // MARK: - Billing Choice info and information dialog (Billing Library 9.1.0+) + // Used in: OpenIapModule.getBillingChoiceInfo() and showBillingProgramInformationDialog() + // ============================================================================ + + @Test + fun `GetBillingChoiceInfoParams class path and builder methods exist`() { + assertClassExists("com.android.billingclient.api.GetBillingChoiceInfoParams", "9.1.0+") + assertClassExists("com.android.billingclient.api.GetBillingChoiceInfoParams\$Builder", "9.1.0+") + assertClassExists("com.android.billingclient.api.GetBillingChoiceInfoParams\$ImageLayout", "9.1.0+") + assertClassHasMethod("com.android.billingclient.api.GetBillingChoiceInfoParams", "newBuilder") + assertClassHasMethod( + "com.android.billingclient.api.GetBillingChoiceInfoParams\$Builder", + "setBillingProgram", + Int::class.javaPrimitiveType!! + ) + assertClassHasMethod( + "com.android.billingclient.api.GetBillingChoiceInfoParams\$Builder", + "setPlayBillingChoiceImageLayout", + String::class.java + ) + assertClassHasMethod( + "com.android.billingclient.api.GetBillingChoiceInfoParams\$Builder", + "setUserLocale", + Locale::class.java + ) + assertClassHasMethod("com.android.billingclient.api.GetBillingChoiceInfoParams\$Builder", "build") + } + + @Test + fun `BillingChoiceInfo and listener methods exist`() { + assertClassHasMethod( + "com.android.billingclient.api.BillingChoiceInfo", + "getPlayBillingChoiceImageUrl" + ) + assertClassHasMethod( + "com.android.billingclient.api.BillingChoiceInfo", + "getPlayBillingLoyaltyInfo" + ) + + val listenerClass = Class.forName("com.android.billingclient.api.BillingChoiceInfoResponseListener") + val methods = listenerClass.declaredMethods.filter { it.name == "onBillingChoiceInfoResponse" } + assertTrue( + "onBillingChoiceInfoResponse method should exist", + methods.isNotEmpty() + ) + assertTrue( + "onBillingChoiceInfoResponse should have 2 parameters", + methods.first().parameterTypes.size == 2 + ) + } + + @Test + fun `EnableBillingProgramParams allows developer-rendered Billing Choice without listener`() { + val paramsClass = Class.forName("com.android.billingclient.api.EnableBillingProgramParams") + val builderClass = Class.forName("com.android.billingclient.api.EnableBillingProgramParams\$Builder") + val builder = paramsClass.getMethod("newBuilder").invoke(null) + + builderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) + .invoke(builder, BillingClient.BillingProgram.BILLING_CHOICE) + + assertNotNull(builderClass.getMethod("build").invoke(builder)) + } + + @Test + fun `BillingProgramInformationDialogParams class path and builder methods exist`() { + assertClassExists("com.android.billingclient.api.BillingProgramInformationDialogParams", "9.1.0+") + assertClassExists("com.android.billingclient.api.BillingProgramInformationDialogParams\$Builder", "9.1.0+") + assertClassHasMethod("com.android.billingclient.api.BillingProgramInformationDialogParams", "newBuilder") + assertClassHasMethod( + "com.android.billingclient.api.BillingProgramInformationDialogParams\$Builder", + "setBillingProgram", + Int::class.javaPrimitiveType!! + ) + assertClassHasMethod( + "com.android.billingclient.api.BillingProgramInformationDialogParams\$Builder", + "setExternalTransactionToken", + String::class.java + ) + assertClassHasMethod("com.android.billingclient.api.BillingProgramInformationDialogParams\$Builder", "build") + } + + @Test + fun `BillingProgramInformationDialogListener callback exists`() { + assertClassHasMethod( + "com.android.billingclient.api.BillingProgramInformationDialogListener", + "onBillingProgramInformationDialogResponse", + com.android.billingclient.api.BillingResult::class.java + ) + } + + // ============================================================================ + // MARK: - In-app messages (Billing Library 4.1.0+) + // Used in: OpenIapModule.showInAppMessages() + // ============================================================================ + + @Test + fun `InAppMessageParams class path and builder methods exist`() { + assertClassExists("com.android.billingclient.api.InAppMessageParams", "4.1.0+") + assertClassExists("com.android.billingclient.api.InAppMessageParams\$Builder", "4.1.0+") + assertClassHasMethod( + "com.android.billingclient.api.InAppMessageParams\$Builder", + "addInAppMessageCategoryToShow", + Int::class.javaPrimitiveType!! + ) + assertClassHasMethod( + "com.android.billingclient.api.InAppMessageParams\$Builder", + "addAllInAppMessageCategoriesToShow" + ) + assertClassHasMethod("com.android.billingclient.api.InAppMessageParams\$Builder", "build") + } + + @Test + fun `InAppMessageResponseListener and result methods exist`() { + assertClassHasMethod( + "com.android.billingclient.api.InAppMessageResponseListener", + "onInAppMessageResponse", + com.android.billingclient.api.InAppMessageResult::class.java + ) + assertClassHasMethod("com.android.billingclient.api.InAppMessageResult", "getResponseCode") + assertClassHasMethod("com.android.billingclient.api.InAppMessageResult", "getPurchaseToken") + assertClassExists( + "com.android.billingclient.api.InAppMessageResult\$InAppMessageResponseCode", + "4.1.0+" + ) + } + // ============================================================================ // MARK: - UserChoiceBillingListener (Billing Library 5.0+) // Used in: OpenIapModule alternative billing mode USER_CHOICE @@ -451,6 +636,21 @@ class BillingLibraryClassPathTest { ) } + @Test + fun `DeveloperProvidedBillingDetails exposes Billing Choice fields`() { + val className = "com.android.billingclient.api.DeveloperProvidedBillingDetails" + assertClassHasMethod(className, "getExternalTransactionToken") + assertClassHasMethod(className, "getLinkUri") + assertClassHasMethod(className, "getOriginalExternalTransactionId") + assertClassHasMethod(className, "getProducts") + + val productClassName = + "com.android.billingclient.api.DeveloperProvidedBillingDetails\$Product" + assertClassHasMethod(productClassName, "getId") + assertClassHasMethod(productClassName, "getType") + assertClassHasMethod(productClassName, "getOfferToken") + } + // ============================================================================ // MARK: - EnableBillingProgramParams (Billing Library 8.3.0+) // Used in: OpenIapModule.enableExternalPaymentsProgram() @@ -570,6 +770,15 @@ class BillingLibraryClassPathTest { ) } + @Test + fun `DeveloperBillingOptionParams Builder has setExternalTransactionToken method`() { + assertClassHasMethod( + "com.android.billingclient.api.DeveloperBillingOptionParams\$Builder", + "setExternalTransactionToken", + String::class.java + ) + } + @Test fun `DeveloperBillingOptionParams Builder has build method`() { assertClassHasMethod( @@ -696,6 +905,75 @@ class BillingLibraryClassPathTest { } } + @Test + fun `BillingClient has getBillingChoiceInfoAsync method`() { + val clientClassName = "com.android.billingclient.api.BillingClient" + val paramsClassName = "com.android.billingclient.api.GetBillingChoiceInfoParams" + val listenerClassName = "com.android.billingclient.api.BillingChoiceInfoResponseListener" + + try { + val clientClass = Class.forName(clientClassName) + val paramsClass = Class.forName(paramsClassName) + val listenerClass = Class.forName(listenerClassName) + val method = clientClass.getMethod("getBillingChoiceInfoAsync", paramsClass, listenerClass) + assertNotNull("getBillingChoiceInfoAsync method should exist", method) + } catch (e: ClassNotFoundException) { + fail("Class not found: ${e.message}") + } catch (e: NoSuchMethodException) { + fail("getBillingChoiceInfoAsync(GetBillingChoiceInfoParams, Listener) not found. Requires Billing Library 9.1.0+") + } + } + + @Test + fun `BillingClient has showBillingProgramInformationDialog method`() { + val clientClassName = "com.android.billingclient.api.BillingClient" + val paramsClassName = "com.android.billingclient.api.BillingProgramInformationDialogParams" + val listenerClassName = "com.android.billingclient.api.BillingProgramInformationDialogListener" + + try { + val clientClass = Class.forName(clientClassName) + val paramsClass = Class.forName(paramsClassName) + val listenerClass = Class.forName(listenerClassName) + val method = clientClass.getMethod( + "showBillingProgramInformationDialog", + android.app.Activity::class.java, + paramsClass, + listenerClass + ) + assertNotNull("showBillingProgramInformationDialog method should exist", method) + assertEquals(Void.TYPE, method.returnType) + } catch (e: ClassNotFoundException) { + fail("Class not found: ${e.message}") + } catch (e: NoSuchMethodException) { + fail("showBillingProgramInformationDialog method not found. Requires Billing Library 9.1.0+") + } + } + + @Test + fun `BillingClient has showInAppMessages method`() { + val clientClassName = "com.android.billingclient.api.BillingClient" + val paramsClassName = "com.android.billingclient.api.InAppMessageParams" + val listenerClassName = "com.android.billingclient.api.InAppMessageResponseListener" + + try { + val clientClass = Class.forName(clientClassName) + val paramsClass = Class.forName(paramsClassName) + val listenerClass = Class.forName(listenerClassName) + val method = clientClass.getMethod( + "showInAppMessages", + android.app.Activity::class.java, + paramsClass, + listenerClass + ) + assertNotNull("showInAppMessages method should exist", method) + assertEquals(com.android.billingclient.api.BillingResult::class.java, method.returnType) + } catch (e: ClassNotFoundException) { + fail("Class not found: ${e.message}") + } catch (e: NoSuchMethodException) { + fail("showInAppMessages method not found. Requires Billing Library 4.1.0+") + } + } + @Test fun `BillingClient has launchExternalLink method`() { val clientClassName = "com.android.billingclient.api.BillingClient" diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingProgramAndroidTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingProgramAndroidTest.kt index b0e9a1c14..b9b31014b 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingProgramAndroidTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingProgramAndroidTest.kt @@ -13,6 +13,7 @@ class BillingProgramAndroidTest { assertEquals(BillingProgramAndroid.ExternalContentLink, BillingProgramAndroid.fromJson("external-content-link")) assertEquals(BillingProgramAndroid.ExternalOffer, BillingProgramAndroid.fromJson("external-offer")) assertEquals(BillingProgramAndroid.ExternalPayments, BillingProgramAndroid.fromJson("external-payments")) + assertEquals(BillingProgramAndroid.BillingChoice, BillingProgramAndroid.fromJson("billing-choice")) } @Test @@ -22,6 +23,7 @@ class BillingProgramAndroidTest { assertEquals(BillingProgramAndroid.ExternalContentLink, BillingProgramAndroid.fromJson("ExternalContentLink")) assertEquals(BillingProgramAndroid.ExternalOffer, BillingProgramAndroid.fromJson("ExternalOffer")) assertEquals(BillingProgramAndroid.ExternalPayments, BillingProgramAndroid.fromJson("ExternalPayments")) + assertEquals(BillingProgramAndroid.BillingChoice, BillingProgramAndroid.fromJson("BillingChoice")) } @Test @@ -38,6 +40,7 @@ class BillingProgramAndroidTest { assertEquals("external-content-link", BillingProgramAndroid.ExternalContentLink.toJson()) assertEquals("external-offer", BillingProgramAndroid.ExternalOffer.toJson()) assertEquals("external-payments", BillingProgramAndroid.ExternalPayments.toJson()) + assertEquals("billing-choice", BillingProgramAndroid.BillingChoice.toJson()) } @Test diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonAppIdMetadataTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonAppIdMetadataTest.kt new file mode 100644 index 000000000..e0bb6b642 --- /dev/null +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonAppIdMetadataTest.kt @@ -0,0 +1,54 @@ +package dev.hyo.openiap + +import android.os.Bundle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [29]) +class HorizonAppIdMetadataTest { + + @Test + fun `canonical Horizon 2 key takes precedence over legacy metadata`() { + val metaData = Bundle().apply { + putString("com.meta.horizon.platform.HORIZON_APP_ID", "canonical") + putString("com.meta.horizon.platform.ovr.OCULUS_APP_ID", "legacy") + } + + assertEquals("canonical", resolveHorizonAppId(metaData)) + } + + @Test + fun `blank canonical key falls back through every historical key`() { + val legacyKeys = listOf( + "com.meta.horizon.platform.ovr.OCULUS_APP_ID", + "com.meta.horizon.platform.ovr.HORIZON_APP_ID", + "com.oculus.vr.APP_ID" + ) + + legacyKeys.forEach { legacyKey -> + val metaData = Bundle().apply { + putString("com.meta.horizon.platform.HORIZON_APP_ID", "") + putString(legacyKey, legacyKey) + } + + assertEquals(legacyKey, resolveHorizonAppId(metaData)) + } + } + + @Test + fun `missing or blank Horizon metadata resolves to null`() { + assertNull(resolveHorizonAppId(null)) + assertNull( + resolveHorizonAppId( + Bundle().apply { + putString("com.meta.horizon.platform.HORIZON_APP_ID", "") + } + ) + ) + } +} diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt index 87c329ee7..525e6e2c2 100644 --- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt @@ -13,6 +13,8 @@ import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingConfigResponseListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingProgramAvailabilityListener +import com.android.billingclient.api.BillingProgramInformationDialogListener +import com.android.billingclient.api.BillingProgramInformationDialogParams import com.android.billingclient.api.BillingProgramReportingDetailsListener import com.android.billingclient.api.BillingProgramReportingDetailsParams import com.android.billingclient.api.BillingResult @@ -21,7 +23,9 @@ import com.android.billingclient.api.ConsumeResponseListener import com.android.billingclient.api.ExternalOfferAvailabilityListener import com.android.billingclient.api.ExternalOfferInformationDialogListener import com.android.billingclient.api.ExternalOfferReportingDetailsListener +import com.android.billingclient.api.GetBillingChoiceInfoParams import com.android.billingclient.api.GetBillingConfigParams +import com.android.billingclient.api.BillingChoiceInfoResponseListener import com.android.billingclient.api.InAppMessageParams import com.android.billingclient.api.InAppMessageResponseListener import com.android.billingclient.api.LaunchExternalLinkParams @@ -318,6 +322,12 @@ class QueryPurchasesRaceTest { listener: InAppMessageResponseListener ): BillingResult = unsupported() + override fun showBillingProgramInformationDialog( + activity: Activity, + params: BillingProgramInformationDialogParams, + listener: BillingProgramInformationDialogListener + ) = unsupportedUnit() + override fun acknowledgePurchase( params: AcknowledgePurchaseParams, listener: AcknowledgePurchaseResponseListener @@ -348,6 +358,11 @@ class QueryPurchasesRaceTest { listener: BillingConfigResponseListener ) = unsupportedUnit() + override fun getBillingChoiceInfoAsync( + params: GetBillingChoiceInfoParams, + listener: BillingChoiceInfoResponseListener + ) = unsupportedUnit() + override fun isAlternativeBillingOnlyAvailableAsync( listener: AlternativeBillingOnlyAvailabilityListener ) = unsupportedUnit() diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/utils/BillingResultConvertersTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/utils/BillingResultConvertersTest.kt new file mode 100644 index 000000000..3bcb9cb22 --- /dev/null +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/utils/BillingResultConvertersTest.kt @@ -0,0 +1,36 @@ +package dev.hyo.openiap.utils + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import dev.hyo.openiap.SubResponseCodeAndroid +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BillingResultConvertersTest { + @Test + fun `maps every Play Billing 9_1 sub-response code`() { + val expected = mapOf( + BillingClient.OnPurchasesUpdatedSubResponseCode.NO_APPLICABLE_SUB_RESPONSE_CODE to + SubResponseCodeAndroid.NoApplicableSubResponseCode, + BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS to + SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds, + BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE to + SubResponseCodeAndroid.UserIneligible + ) + + expected.forEach { (nativeCode, openIapCode) -> + val result = BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.ERROR) + .setOnPurchasesUpdatedSubResponseCode(nativeCode) + .build() + + assertEquals(openIapCode, result.toOpenIapBillingResult().subResponseCode) + } + } + + @Test + fun `unknown sub-response code remains absent`() { + assertNull(Int.MAX_VALUE.toOpenIapSubResponseCode()) + } +} diff --git a/packages/gql/CONVENTION.md b/packages/gql/CONVENTION.md index 8316ee4d4..75526b70b 100644 --- a/packages/gql/CONVENTION.md +++ b/packages/gql/CONVENTION.md @@ -42,6 +42,16 @@ This repo standardizes schema and identifier naming to improve clarity across pl - Example: `type: ProductQueryType = inApp`. - Document defaults in the field description if behavior matters. +## API Availability Comments + +- Lead with the OpenIAP release that exposes the API, then state the upstream + platform requirement as compatibility context. +- Android example: `Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +(requires Play Billing 9.1.0+).` +- Do not document a new OpenIAP field only as `Play Billing 9.1.0+`; consumers + need the spec and platform-package versions to know which SDK release contains + it. + ## Unions - Cross‑platform unions combine platform types (e.g., `Product = ProductAndroid | ProductIOS`). diff --git a/packages/gql/codegen/core/transformer.ts b/packages/gql/codegen/core/transformer.ts index 36a538c9e..bb69332b7 100644 --- a/packages/gql/codegen/core/transformer.ts +++ b/packages/gql/codegen/core/transformer.ts @@ -23,6 +23,7 @@ import { type GraphQLField, type GraphQLInputField, type GraphQLArgument, + valueFromASTUntyped, } from 'graphql'; import type { IRSchema, @@ -288,6 +289,9 @@ export class SchemaTransformer { description: field.description ?? undefined, type: this.transformType(field.type), isOverride: false, + defaultValue: field.astNode?.defaultValue + ? valueFromASTUntyped(field.astNode.defaultValue) + : undefined, })); return { @@ -388,6 +392,9 @@ export class SchemaTransformer { description: field.description ?? undefined, type: this.transformType(field.type), isOverride: false, + defaultValue: field.astNode?.defaultValue + ? valueFromASTUntyped(field.astNode.defaultValue) + : undefined, })); const hasRequiredFields = graphqlFields.some( diff --git a/packages/gql/codegen/core/types.ts b/packages/gql/codegen/core/types.ts index 6ed0458bd..a6f9352ba 100644 --- a/packages/gql/codegen/core/types.ts +++ b/packages/gql/codegen/core/types.ts @@ -68,8 +68,8 @@ export interface IRField { type: IRType; /** Whether this field overrides an interface field */ isOverride: boolean; - /** Default value for discriminated unions (e.g., platform: 'ios') */ - defaultValue?: string; + /** Default value from SDL or generated discriminated union defaults. */ + defaultValue?: unknown; } // ============================================================================ diff --git a/packages/gql/codegen/plugins/csharp.ts b/packages/gql/codegen/plugins/csharp.ts index 9696f7972..b6f62fc4f 100644 --- a/packages/gql/codegen/plugins/csharp.ts +++ b/packages/gql/codegen/plugins/csharp.ts @@ -409,7 +409,16 @@ export class CSharpPlugin extends CodegenPlugin { // `required` modifier so callers must initialize them; nullable // properties default to null. if (field.type.nullable) { - this.emit(` public ${propType} ${propName} { get; init; }`); + const defaultValue = this.buildDefaultValueExpression(field); + const initializer = defaultValue ? ` = ${defaultValue};` : ''; + this.emit(` public ${propType} ${propName} { get; init; }${initializer}`); + } else if (field.defaultValue !== undefined) { + const defaultValue = this.buildDefaultValueExpression(field); + if (defaultValue) { + this.emit(` public ${propType} ${propName} { get; init; } = ${defaultValue};`); + } else { + this.emit(` public required ${propType} ${propName} { get; init; }`); + } } else if (defaults && field.name === 'platform') { const defaultValue = `IapPlatform.${toPascalCasePreserveIOS(defaults.platform)}`; this.emit(` public ${propType} ${propName} { get; init; } = ${defaultValue};`); @@ -422,6 +431,41 @@ export class CSharpPlugin extends CodegenPlugin { }); } + private buildDefaultValueExpression(field: IRField): string | null { + if (field.defaultValue === undefined) return null; + return this.buildDefaultValueForType(field.type, field.defaultValue); + } + + private buildDefaultValueForType(type: IRType, defaultValue: unknown): string | null { + if (type.kind === 'list') { + if (!Array.isArray(defaultValue)) return null; + const itemType = this.mapType(type.elementType!); + const items = defaultValue + .map((value) => this.buildDefaultValueForType(type.elementType!, value)) + .filter((value): value is string => value !== null); + return `new List<${itemType}> { ${items.join(', ')} }`; + } + if (type.kind === 'enum' && typeof defaultValue === 'string') { + return `global::${NAMESPACE}.${type.name}.${this.enumValueCase(defaultValue)}`; + } + if (type.kind === 'scalar') { + if (typeof defaultValue === 'string') return this.csharpStringLiteral(defaultValue); + if (typeof defaultValue === 'number' || typeof defaultValue === 'boolean') { + return String(defaultValue).toLowerCase(); + } + } + return null; + } + + private csharpStringLiteral(value: string): string { + return `"${value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\t/g, '\\t')}"`; + } + private generateResultUnionObject(irObject: IRObject): void { this.emitDoc(irObject.description); const entries = [...irObject.resultUnionEntries!].sort((a, b) => diff --git a/packages/gql/codegen/plugins/dart.ts b/packages/gql/codegen/plugins/dart.ts index 07b8c961a..883fed1af 100644 --- a/packages/gql/codegen/plugins/dart.ts +++ b/packages/gql/codegen/plugins/dart.ts @@ -398,7 +398,10 @@ export class DartPlugin extends CodegenPlugin { const sortedFields = [...irInput.fields].sort((a, b) => a.name.localeCompare(b.name)); for (const field of sortedFields) { - if (field.type.nullable) { + const defaultValue = this.buildDefaultValueExpression(field); + if (defaultValue) { + this.emit(` this.${this.escapeKeyword(field.name)} = ${defaultValue},`); + } else if (field.type.nullable) { this.emit(` this.${this.escapeKeyword(field.name)},`); } else { this.emit(` required this.${this.escapeKeyword(field.name)},`); @@ -420,7 +423,11 @@ export class DartPlugin extends CodegenPlugin { this.emit(` factory ${irInput.name}.fromJson(Map json) {`); this.emit(` return ${irInput.name}(`); for (const field of sortedFields) { - const jsonExpr = this.buildFromJsonExpression(field.type, `json['${field.name}']`); + const jsonExpr = this.buildFromJsonExpression( + field.type, + `json['${field.name}']`, + this.buildDefaultValueExpression(field) + ); this.emit(` ${this.escapeKeyword(field.name)}: ${jsonExpr},`); } this.emit(' );'); @@ -828,11 +835,14 @@ export class DartPlugin extends CodegenPlugin { return this.getPropertyType(field.returnType); } - private buildFromJsonExpression(type: IRType, sourceExpr: string): string { + private buildFromJsonExpression(type: IRType, sourceExpr: string, defaultExpression?: string | null): string { if (type.kind === 'list') { const listCast = `(${sourceExpr} as List${type.nullable ? '?' : ''})`; const elementExpr = this.buildFromJsonExpression(type.elementType!, 'e'); const mapCall = (target: string) => `${target}.map((e) => ${elementExpr}).toList()`; + if (defaultExpression) { + return `${listCast} == null ? ${defaultExpression} : ${mapCall(`${listCast}${type.nullable ? '!' : ''}`)}`; + } if (type.nullable) { return `${listCast} == null ? null : ${mapCall(`${listCast}!`)}`; } @@ -842,15 +852,27 @@ export class DartPlugin extends CodegenPlugin { if (type.kind === 'scalar') { switch (type.name) { case 'Float': + if (defaultExpression) { + return `${sourceExpr} == null ? ${defaultExpression} : (${sourceExpr} as num).toDouble()`; + } return type.nullable ? `(${sourceExpr} as num?)?.toDouble()` : `(${sourceExpr} as num).toDouble()`; case 'Int': + if (defaultExpression) { + return `${sourceExpr} == null ? ${defaultExpression} : ${sourceExpr} as int`; + } return type.nullable ? `${sourceExpr} as int?` : `${sourceExpr} as int`; case 'Boolean': + if (defaultExpression) { + return `${sourceExpr} == null ? ${defaultExpression} : ${sourceExpr} as bool`; + } return type.nullable ? `${sourceExpr} as bool?` : `${sourceExpr} as bool`; case 'ID': case 'String': + if (defaultExpression) { + return `${sourceExpr} == null ? ${defaultExpression} : ${sourceExpr} as String`; + } return type.nullable ? `${sourceExpr} as String?` : `${sourceExpr} as String`; default: return sourceExpr; @@ -858,6 +880,9 @@ export class DartPlugin extends CodegenPlugin { } if (type.kind === 'enum') { + if (defaultExpression) { + return `${sourceExpr} != null ? ${type.name}.fromJson(${sourceExpr} as String) : ${defaultExpression}`; + } return type.nullable ? `${sourceExpr} != null ? ${type.name}.fromJson(${sourceExpr} as String) : null` : `${type.name}.fromJson(${sourceExpr} as String)`; @@ -872,6 +897,31 @@ export class DartPlugin extends CodegenPlugin { return sourceExpr; } + private buildDefaultValueExpression(field: IRField): string | null { + if (field.defaultValue === undefined) return null; + return this.buildDefaultValueForType(field.type, field.defaultValue); + } + + private buildDefaultValueForType(type: IRType, defaultValue: unknown): string | null { + if (type.kind === 'list') { + if (!Array.isArray(defaultValue)) return null; + const items = defaultValue + .map((value) => this.buildDefaultValueForType(type.elementType!, value)) + .filter((value): value is string => value !== null); + return `const [${items.join(', ')}]`; + } + if (type.kind === 'enum' && typeof defaultValue === 'string') { + return `${type.name}.${this.escapeKeyword(this.enumValueCase(defaultValue))}`; + } + if (type.kind === 'scalar') { + if (typeof defaultValue === 'string') return `'${defaultValue}'`; + if (typeof defaultValue === 'number' || typeof defaultValue === 'boolean') { + return String(defaultValue); + } + } + return null; + } + private buildToJsonExpression(type: IRType, accessorExpr: string): string { if (type.kind === 'list') { const inner = this.buildToJsonExpression(type.elementType!, 'e'); diff --git a/packages/gql/codegen/plugins/gdscript.ts b/packages/gql/codegen/plugins/gdscript.ts index 1f3156a34..8b583222c 100644 --- a/packages/gql/codegen/plugins/gdscript.ts +++ b/packages/gql/codegen/plugins/gdscript.ts @@ -124,6 +124,30 @@ export class GDScriptPlugin extends CodegenPlugin { } } + private getSchemaDefaultValue(field: IRField): string | null { + if (field.defaultValue === undefined || field.defaultValue === null) { + return null; + } + + if (field.type.kind === 'enum' && typeof field.defaultValue === 'string') { + return `${field.type.name}.${this.enumValueCase(field.defaultValue)}`; + } + + if (field.type.kind === 'scalar') { + if (typeof field.defaultValue === 'string') { + return JSON.stringify(field.defaultValue); + } + if ( + typeof field.defaultValue === 'number' || + typeof field.defaultValue === 'boolean' + ) { + return String(field.defaultValue); + } + } + + return null; + } + private getGdscriptFieldName(fieldName: string, typeName: string | null = null): string { if (typeName) { const key = `${typeName}.${fieldName}`; @@ -329,6 +353,44 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit(`${indent}\t${target} = enum_str`); } + private emitEnumListFromDictAssignment(indent: string, target: string, typeName: string, sourceExpression: string): void { + const enumReverseLookup = toConstantCase(typeName) + '_FROM_STRING'; + const fallback = this.getEnumUnknownFallback(typeName); + + this.emit(`${indent}var arr: Array[${typeName}] = []`); + this.emit(`${indent}for item in ${sourceExpression}:`); + if (fallback) { + this.emit(`${indent}\tif item is String:`); + this.emit(`${indent}\t\tarr.append(${enumReverseLookup}.get(item, ${fallback}))`); + this.emit(`${indent}\telse:`); + this.emit(`${indent}\t\tarr.append(item)`); + } else { + this.emit(`${indent}\tif item is String and ${enumReverseLookup}.has(item):`); + this.emit(`${indent}\t\tarr.append(${enumReverseLookup}[item])`); + this.emit(`${indent}\telse:`); + this.emit(`${indent}\t\tarr.append(item)`); + } + this.emit(`${indent}${target} = arr`); + } + + private emitEnumListToDictAssignment(indent: string, graphqlName: string, fieldName: string, typeName: string): void { + const enumConstName = toConstantCase(typeName) + '_VALUES'; + + this.emit(`${indent}var arr = []`); + this.emit(`${indent}for item in ${fieldName}:`); + this.emit(`${indent}\tif ${enumConstName}.has(item):`); + this.emit(`${indent}\t\tarr.append(${enumConstName}[item])`); + this.emit(`${indent}\telse:`); + this.emit(`${indent}\t\tarr.append(item)`); + this.emit(`${indent}dict["${graphqlName}"] = arr`); + } + + private isEnumList(type: IRType): boolean { + return type.kind === 'list' && + !!type.elementType && + (type.elementType.kind === 'enum' || this.enumNames.has(type.elementType.name!)); + } + // ============================================================================ // Interfaces (not used in GDScript, but required by base class) // ============================================================================ @@ -485,7 +547,12 @@ export class GDScriptPlugin extends CodegenPlugin { const type = field.type; const enumConstName = type.name ? toConstantCase(type.name) + '_VALUES' : ''; - if (this.isObjectOrInput(type) && type.kind === 'list') { + if (this.isEnumList(type)) { + this.emit(`\t\tif ${fieldName} != null:`); + this.emitEnumListToDictAssignment('\t\t\t', graphqlName, fieldName, type.elementType!.name!); + this.emit(`\t\telse:`); + this.emit(`\t\t\tdict["${graphqlName}"] = null`); + } else if (this.isObjectOrInput(type) && type.kind === 'list') { this.emit(`\t\tif ${fieldName} != null:`); this.emit(`\t\t\tvar arr = []`); this.emit(`\t\t\tfor item in ${fieldName}:`); @@ -564,8 +631,11 @@ export class GDScriptPlugin extends CodegenPlugin { } const gdType = this.mapType(field.type); const fieldName = this.getGdscriptFieldName(field.name, irInput.name); + const schemaDefaultValue = this.getSchemaDefaultValue(field); const defaultValue = this.getDefaultValue(field.type); - if (field.type.nullable && this.isNullableScalar(field.type)) { + if (schemaDefaultValue !== null) { + this.emit(`\tvar ${fieldName}: ${gdType} = ${schemaDefaultValue}`); + } else if (field.type.nullable && this.isNullableScalar(field.type)) { this.emit(`\tvar ${fieldName}: Variant = null`); } else if (defaultValue !== null) { this.emit(`\tvar ${fieldName}: ${gdType} = ${defaultValue}`); @@ -626,7 +696,9 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit(`\t\tif ${fieldName} != null:`); - if (this.isObjectOrInput(type) && type.kind === 'list') { + if (this.isEnumList(type)) { + this.emitEnumListToDictAssignment('\t\t\t', graphqlName, fieldName, type.elementType!.name!); + } else if (this.isObjectOrInput(type) && type.kind === 'list') { this.emit(`\t\t\tvar arr = []`); this.emit(`\t\t\tfor item in ${fieldName}:`); this.emit(`\t\t\t\tif item.has_method("to_dict"):`); diff --git a/packages/gql/codegen/plugins/kotlin.ts b/packages/gql/codegen/plugins/kotlin.ts index 9ec015f46..e3ff9b6a2 100644 --- a/packages/gql/codegen/plugins/kotlin.ts +++ b/packages/gql/codegen/plugins/kotlin.ts @@ -349,7 +349,8 @@ export class KotlinPlugin extends CodegenPlugin { const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); const suffix = index === sortedFields.length - 1 ? '' : ','; - const defaultValue = field.type.nullable ? ' = null' : ''; + const schemaDefault = this.buildDefaultValueExpression(field); + const defaultValue = schemaDefault ? ` = ${schemaDefault}` : field.type.nullable ? ' = null' : ''; this.emit(` val ${propertyName}: ${propertyType}${defaultValue}${suffix}`); }); @@ -357,20 +358,26 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(' companion object {'); // Check if input has required fields - const hasRequiredFields = sortedFields.some((f) => !f.type.nullable); + const hasRequiredFields = sortedFields.some((f) => !f.type.nullable && !this.hasSchemaDefault(f)); if (hasRequiredFields) { // Nullable fromJson pattern this.emit(` fun fromJson(json: Map): ${irInput.name}? {`); for (const field of sortedFields) { const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); - const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`, false, true); + const expression = this.buildFromJsonExpression( + field.type, + `json["${field.name}"]`, + false, + true, + this.buildDefaultValueExpression(field) + ); this.emit(` val ${propertyName} = ${expression}`); } // Null check for required fields (excluding enums which have fallbacks) const requiredFields = sortedFields.filter( - (f) => !f.type.nullable && f.type.kind !== 'enum' + (f) => !f.type.nullable && !this.hasSchemaDefault(f) && f.type.kind !== 'enum' ); if (requiredFields.length > 0) { const nullChecks = requiredFields @@ -392,7 +399,13 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(` return ${irInput.name}(`); for (const field of sortedFields) { const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); - const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`); + const expression = this.buildFromJsonExpression( + field.type, + `json["${field.name}"]`, + false, + false, + this.buildDefaultValueExpression(field) + ); this.emit(` ${propertyName} = ${expression},`); } this.emit(' )'); @@ -442,7 +455,8 @@ export class KotlinPlugin extends CodegenPlugin { const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); const suffix = index === irInput.fields.length - 1 ? '' : ','; - const defaultValue = field.type.nullable ? ' = null' : ''; + const schemaDefault = this.buildDefaultValueExpression(field); + const defaultValue = schemaDefault ? ` = ${schemaDefault}` : field.type.nullable ? ' = null' : ''; this.emit(` val ${propertyName}: ${propertyType}${defaultValue}${suffix}`); }); @@ -450,20 +464,26 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(' companion object {'); // Check if input has required fields - const hasRequiredFields = irInput.fields.some((f) => !f.type.nullable); + const hasRequiredFields = irInput.fields.some((f) => !f.type.nullable && !this.hasSchemaDefault(f)); if (hasRequiredFields) { // Nullable fromJson pattern this.emit(` fun fromJson(json: Map): ${irInput.name}? {`); for (const field of irInput.fields) { const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); - const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`, false, true); + const expression = this.buildFromJsonExpression( + field.type, + `json["${field.name}"]`, + false, + true, + this.buildDefaultValueExpression(field) + ); this.emit(` val ${propertyName} = ${expression}`); } // Null check for required fields (excluding enums which have fallbacks) const requiredFields = irInput.fields.filter( - (f) => !f.type.nullable && f.type.kind !== 'enum' + (f) => !f.type.nullable && !this.hasSchemaDefault(f) && f.type.kind !== 'enum' ); if (requiredFields.length > 0) { const nullChecks = requiredFields @@ -485,7 +505,13 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(` return ${irInput.name}(`); for (const field of irInput.fields) { const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); - const expression = this.buildFromJsonExpression(field.type, `json["${field.name}"]`); + const expression = this.buildFromJsonExpression( + field.type, + `json["${field.name}"]`, + false, + false, + this.buildDefaultValueExpression(field) + ); this.emit(` ${propertyName} = ${expression},`); } this.emit(' )'); @@ -729,11 +755,15 @@ export class KotlinPlugin extends CodegenPlugin { type: IRType, sourceExpr: string, isListElement: boolean = false, - forNullableFromJson: boolean = false + forNullableFromJson: boolean = false, + defaultExpression?: string | null ): string { if (type.kind === 'list') { const element = this.buildFromJsonExpression(type.elementType!, 'it', true, forNullableFromJson); const mapFn = type.elementType!.nullable ? 'map' : 'mapNotNull'; + if (defaultExpression) { + return `(${sourceExpr} as? List<*>)?.${mapFn} { ${element} } ?: ${defaultExpression}`; + } if (type.nullable || forNullableFromJson) { return `(${sourceExpr} as? List<*>)?.${mapFn} { ${element} }`; } @@ -744,20 +774,32 @@ export class KotlinPlugin extends CodegenPlugin { const useNullable = type.nullable || isListElement || forNullableFromJson; switch (type.name) { case 'Float': + if (defaultExpression) { + return `(${sourceExpr} as? Number)?.toDouble() ?: ${defaultExpression}`; + } return useNullable ? `(${sourceExpr} as? Number)?.toDouble()` : `(${sourceExpr} as? Number)?.toDouble() ?: 0.0`; case 'Int': + if (defaultExpression) { + return `(${sourceExpr} as? Number)?.toInt() ?: ${defaultExpression}`; + } return useNullable ? `(${sourceExpr} as? Number)?.toInt()` : `(${sourceExpr} as? Number)?.toInt() ?: 0`; case 'Boolean': + if (defaultExpression) { + return `${sourceExpr} as? Boolean ?: ${defaultExpression}`; + } return useNullable ? `${sourceExpr} as? Boolean` : `${sourceExpr} as? Boolean ?: false`; case 'ID': case 'String': default: + if (defaultExpression) { + return `${sourceExpr} as? String ?: ${defaultExpression}`; + } return useNullable ? `${sourceExpr} as? String` : `${sourceExpr} as? String ?: ""`; @@ -765,8 +807,19 @@ export class KotlinPlugin extends CodegenPlugin { } if (type.kind === 'enum') { + const unknownFallback = this.buildUnknownEnumFallbackExpression(type); + const enumRead = `(${sourceExpr} as? String)?.let { ${type.name}.fromJson(it) }`; + if (defaultExpression) { + return `${enumRead} ?: ${defaultExpression}`; + } + if (unknownFallback) { + if (type.nullable) { + return `(${sourceExpr} as? String)?.let { runCatching { ${type.name}.fromJson(it) }.getOrNull() ?: ${unknownFallback} }`; + } + return `runCatching { ${enumRead} }.getOrNull() ?: ${unknownFallback}`; + } if (type.nullable) { - return `(${sourceExpr} as? String)?.let { ${type.name}.fromJson(it) }`; + return enumRead; } // Find if enum has Empty value const irEnum = this.schema.enums.find((e) => e.name === type.name); @@ -819,6 +872,48 @@ export class KotlinPlugin extends CodegenPlugin { return accessorExpr; } + private hasSchemaDefault(field: IRField): boolean { + return field.defaultValue !== undefined; + } + + private buildDefaultValueExpression(field: IRField): string | null { + if (!this.hasSchemaDefault(field)) return null; + return this.buildDefaultValueForType(field.type, field.defaultValue); + } + + private buildDefaultValueForType(type: IRType, defaultValue: unknown): string | null { + if (type.kind === 'list') { + if (!Array.isArray(defaultValue)) return null; + const items = defaultValue + .map((value) => this.buildDefaultValueForType(type.elementType!, value)) + .filter((value): value is string => value !== null); + return `listOf(${items.join(', ')})`; + } + if (type.kind === 'enum' && typeof defaultValue === 'string') { + return `${type.name}.${this.escapeKeyword(this.enumValueCase(defaultValue))}`; + } + if (type.kind === 'scalar') { + if (typeof defaultValue === 'string') return `"${defaultValue}"`; + if (typeof defaultValue === 'boolean') return String(defaultValue); + if (typeof defaultValue === 'number') { + if (type.name === 'Float' && Number.isInteger(defaultValue)) { + return `${defaultValue}.0`; + } + return String(defaultValue); + } + } + return null; + } + + private buildUnknownEnumFallbackExpression(type: IRType): string | null { + if (type.kind !== 'enum' || !type.name) return null; + const irEnum = this.schema.enums.find((e) => e.name === type.name); + const unknownValue = irEnum?.values.find((value) => value.name.toLowerCase().startsWith('unknown')); + return unknownValue + ? `${type.name}.${this.escapeKeyword(this.enumValueCase(unknownValue.name))}` + : null; + } + // ============================================================================ // Helpers // ============================================================================ diff --git a/packages/gql/codegen/plugins/swift.ts b/packages/gql/codegen/plugins/swift.ts index 9b39f4875..95dfd75fc 100644 --- a/packages/gql/codegen/plugins/swift.ts +++ b/packages/gql/codegen/plugins/swift.ts @@ -275,7 +275,8 @@ export class SwiftPlugin extends CodegenPlugin { .map((field) => { const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); - const defaultValue = field.type.nullable ? ' = nil' : ''; + const schemaDefault = this.buildDefaultValueExpression(field); + const defaultValue = schemaDefault ? ` = ${schemaDefault}` : field.type.nullable ? ' = nil' : ''; return ` ${propertyName}: ${propertyType}${defaultValue}`; }) .join(',\n'); @@ -310,6 +311,31 @@ export class SwiftPlugin extends CodegenPlugin { } } + private buildDefaultValueExpression(field: IRField): string | null { + if (field.defaultValue === undefined) return null; + return this.buildDefaultValueForType(field.type, field.defaultValue); + } + + private buildDefaultValueForType(type: IRType, defaultValue: unknown): string | null { + if (type.kind === 'list') { + if (!Array.isArray(defaultValue)) return null; + const items = defaultValue + .map((value) => this.buildDefaultValueForType(type.elementType!, value)) + .filter((value): value is string => value !== null); + return `[${items.join(', ')}]`; + } + if (type.kind === 'enum' && typeof defaultValue === 'string') { + return `.${this.escapeKeyword(this.enumValueCase(defaultValue))}`; + } + if (type.kind === 'scalar') { + if (typeof defaultValue === 'string') return `"${defaultValue}"`; + if (typeof defaultValue === 'number' || typeof defaultValue === 'boolean') { + return String(defaultValue); + } + } + return null; + } + private generateDiscountOfferInputIOS(irInput: IRInput): void { this.generateDocComment(irInput.description); this.emit('public struct DiscountOfferInputIOS: Codable {'); diff --git a/packages/gql/package.json b/packages/gql/package.json index 5188b57cc..bde9d1a11 100644 --- a/packages/gql/package.json +++ b/packages/gql/package.json @@ -1,6 +1,6 @@ { "name": "@hyodotdev/openiap-gql", - "version": "2.0.3", + "version": "2.1.0", "type": "module", "main": "src/generated/types.ts", "exports": { diff --git a/packages/gql/src/api-android.graphql b/packages/gql/src/api-android.graphql index a83a04d92..8cc98ce64 100644 --- a/packages/gql/src/api-android.graphql +++ b/packages/gql/src/api-android.graphql @@ -1,5 +1,18 @@ # Android-specific API operations +extend type Query { + """ + Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + Throws OpenIapError.NotPrepared if billing client is not ready. + See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + """ + # Future + getBillingChoiceInfoAndroid( + params: GetBillingChoiceInfoParamsAndroid! + ): BillingChoiceInfoAndroid! +} + extend type Mutation { """ Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. @@ -17,7 +30,6 @@ extend type Mutation { # Alternative Billing APIs """ Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - Returns true if available, false otherwise. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -27,7 +39,6 @@ extend type Mutation { """ Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. Must be called BEFORE processing payment in your payment system. - Returns true if user accepted, false if user canceled. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android @@ -38,7 +49,6 @@ extend type Mutation { Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. Must be called AFTER successful payment in your payment system. Token must be reported to Google Play backend within 24 hours. - Returns token string, or null if creation failed. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -50,34 +60,68 @@ extend type Mutation { """ Check whether a billing program (e.g., External Payments) is available for the current user. Replaces the deprecated isExternalOfferAvailableAsync API. - Available in Google Play Billing Library 8.2.0+. Returns availability result with isAvailable flag. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/is-billing-program-available-android """ # Future - isBillingProgramAvailableAndroid(program: BillingProgramAndroid!): BillingProgramAvailabilityResultAndroid! + isBillingProgramAvailableAndroid( + program: BillingProgramAndroid! + ): BillingProgramAvailabilityResultAndroid! """ Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). Replaces the deprecated createExternalOfferReportingDetailsAsync API. - Returns external transaction token needed for reporting external transactions. + developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + native Android defaults it to IN_APP. + The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + openiap-google 2.3.0 (requires Play Billing 9.1.0+). Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android """ # Future - createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid!): BillingProgramReportingDetailsAndroid! + createBillingProgramReportingDetailsAndroid( + program: BillingProgramAndroid! + developerBillingType: DeveloperBillingTypeAndroid + ): BillingProgramReportingDetailsAndroid! """ - Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + including developer-rendered Billing Choice external-link flows. + Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + (requires Play Billing 9.1.0+). Replaces the deprecated showExternalOfferInformationDialog API. - Shows Play Store dialog and optionally launches external URL. Throws OpenIapError.NotPrepared if billing client not ready. See: https://openiap.dev/docs/apis/android/launch-external-link-android """ # Future launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid!): Boolean! + + """ + Show Google's mandatory information dialog before a developer-rendered, + in-app Billing Choice screen. + OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + Throws OpenIapError.NotPrepared if billing client not ready. + See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + """ + # Future + showBillingProgramInformationDialogAndroid( + params: BillingProgramInformationDialogParamsAndroid! + ): BillingResultAndroid! + + """ + Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + (upstream API available since Play Billing 4.1.0). + Returns a response code and, when the subscription status changes, the related purchase token. + Throws OpenIapError.NotPrepared if billing client not ready. + See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + """ + # Future + showInAppMessagesAndroid( + params: InAppMessageParamsAndroid + ): InAppMessageResultAndroid! } diff --git a/packages/gql/src/codegen-defaults.test.ts b/packages/gql/src/codegen-defaults.test.ts new file mode 100644 index 000000000..1b683f49a --- /dev/null +++ b/packages/gql/src/codegen-defaults.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { CSharpPlugin } from "../codegen/plugins/csharp"; +import { GDScriptPlugin } from "../codegen/plugins/gdscript"; +import { KotlinPlugin } from "../codegen/plugins/kotlin"; +import type { IREnum, IRField, IRSchema, IRType } from "../codegen/core/types"; + +const stringType: IRType = { kind: "scalar", name: "String", nullable: false }; +const floatType: IRType = { kind: "scalar", name: "Float", nullable: false }; + +function field(name: string, type: IRType, defaultValue?: unknown): IRField { + return { + name, + type, + isOverride: false, + ...(defaultValue !== undefined ? { defaultValue } : {}), + }; +} + +function schema(fields: IRField[], enums: IREnum[] = []): IRSchema { + return { + enums, + interfaces: [], + objects: [], + inputs: [ + { + name: "DefaultInput", + fields, + hasRequiredFields: true, + isCustomType: false, + }, + ], + unions: [], + operations: [], + metadata: { + unionWrapperNames: new Set(), + futureFieldNames: new Set(), + platformDefaults: new Map(), + singleFieldObjects: new Map(), + unionMembership: new Map(), + inputsWithRequiredFields: new Set(), + }, + }; +} + +describe("codegen defaults", () => { + it("keeps unsupported non-null C# defaults required and escapes string literals", () => { + const output = new CSharpPlugin({ outputPath: "Types.cs" }).generate( + schema([ + field("unsupportedDefault", stringType, { raw: "unsupported" }), + field("escapedString", stringType, 'quote " and slash \\'), + ]), + ); + + expect(output).toContain( + "public required string UnsupportedDefault { get; init; }", + ); + expect(output).toContain( + 'public string EscapedString { get; init; } = "quote \\" and slash \\\\";', + ); + }); + + it("emits whole-number GraphQL Float defaults as Kotlin Double literals", () => { + const output = new KotlinPlugin({ + outputPath: "Types.kt", + packageName: "dev.hyo.openiap", + }).generate( + schema([ + field("wholeWeight", floatType, 0), + field("fractionalWeight", floatType, 1.5), + ]), + ); + + expect(output).toContain("val wholeWeight: Double = 0.0"); + expect(output).toContain("val fractionalWeight: Double = 1.5,"); + }); + + it("emits GraphQL enum defaults as GDScript field initializers", () => { + const rendererEnum: IREnum = { + name: "Renderer", + isErrorCode: false, + values: [ + { + name: "UNSPECIFIED", + rawValue: "unspecified", + legacyAliases: [], + }, + { + name: "GOOGLE_RENDERED", + rawValue: "google-rendered", + legacyAliases: [], + }, + ], + }; + const rendererType: IRType = { + kind: "enum", + name: "Renderer", + nullable: true, + }; + const output = new GDScriptPlugin({ outputPath: "types.gd" }).generate( + schema( + [field("renderer", rendererType, "GOOGLE_RENDERED")], + [rendererEnum], + ), + ); + + expect(output).toContain( + "var renderer: Renderer = Renderer.GOOGLE_RENDERED", + ); + + const csharpOutput = new CSharpPlugin({ outputPath: "Types.cs" }).generate( + schema( + [field("renderer", rendererType, "GOOGLE_RENDERED")], + [rendererEnum], + ), + ); + expect(csharpOutput).toContain( + "public Renderer? Renderer { get; init; } = global::OpenIap.Renderer.GoogleRendered;", + ); + }); +}); diff --git a/packages/gql/src/event.graphql b/packages/gql/src/event.graphql index e0d12f89f..8fa60e2ae 100644 --- a/packages/gql/src/event.graphql +++ b/packages/gql/src/event.graphql @@ -22,11 +22,11 @@ extend type Subscription { """ userChoiceBillingAndroid: UserChoiceBillingDetails! """ - Fires when a user selects developer billing in the External Payments flow (Android only) - Triggered when the user chooses to pay via the developer's external payment option - instead of Google Play Billing in the side-by-side choice dialog. - Contains the externalTransactionToken needed to report the transaction. - Available in Google Play Billing Library 8.3.0+ + Fires when a user selects developer billing in an External Payments or + Billing Choice flow (Android only). The payload can contain an external + transaction token, link URI, original transaction ID, and selected products. + Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + openiap-google 2.3.0 (requires Play Billing 9.1.0+). """ developerProvidedBillingAndroid: DeveloperProvidedBillingDetailsAndroid! """ diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index d35ad4222..9b60900f3 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -78,8 +78,120 @@ public static class AlternativeBillingModeAndroidExtensions public static AlternativeBillingModeAndroid FromJson(string value) => AlternativeBillingModeAndroidJsonConverter.FromRawString(value); } -/// Billing program types for external content links, external offers, and external payments (Android) -/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +/// Play Billing choice image layout (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +[JsonConverter(typeof(BillingChoiceImageLayoutAndroidJsonConverter))] +public enum BillingChoiceImageLayoutAndroid +{ + /// Rectangular image with a 4:1 aspect ratio. + RectangularFourByOne, + /// Rectangular image with a 3:1 aspect ratio. + RectangularThreeByOne, + /// Rectangular image with a 2:2 aspect ratio. + RectangularTwoByTwo +} + +public sealed class BillingChoiceImageLayoutAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["rectangular-four-by-one"] = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + ["RECTANGULAR_FOUR_BY_ONE"] = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + ["rectangular-three-by-one"] = BillingChoiceImageLayoutAndroid.RectangularThreeByOne, + ["RECTANGULAR_THREE_BY_ONE"] = BillingChoiceImageLayoutAndroid.RectangularThreeByOne, + ["rectangular-two-by-two"] = BillingChoiceImageLayoutAndroid.RectangularTwoByTwo, + ["RECTANGULAR_TWO_BY_TWO"] = BillingChoiceImageLayoutAndroid.RectangularTwoByTwo, + }; + + private static readonly Dictionary _toString = new() + { + [BillingChoiceImageLayoutAndroid.RectangularFourByOne] = "rectangular-four-by-one", + [BillingChoiceImageLayoutAndroid.RectangularThreeByOne] = "rectangular-three-by-one", + [BillingChoiceImageLayoutAndroid.RectangularTwoByTwo] = "rectangular-two-by-two", + }; + + public override BillingChoiceImageLayoutAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown BillingChoiceImageLayoutAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, BillingChoiceImageLayoutAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(BillingChoiceImageLayoutAndroid value) => _toString[value]; + internal static BillingChoiceImageLayoutAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown BillingChoiceImageLayoutAndroid value: {value}"); +} + +public static class BillingChoiceImageLayoutAndroidExtensions +{ + public static string ToJson(this BillingChoiceImageLayoutAndroid value) => BillingChoiceImageLayoutAndroidJsonConverter.ToRawString(value); + public static BillingChoiceImageLayoutAndroid FromJson(string value) => BillingChoiceImageLayoutAndroidJsonConverter.FromRawString(value); +} + +/// Choice screen renderer for Billing Choice availability (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +[JsonConverter(typeof(BillingChoiceScreenTypeAndroidJsonConverter))] +public enum BillingChoiceScreenTypeAndroid +{ + /// Unspecified choice screen type. + Unspecified, + /// Choice screen is rendered by the developer app. + DeveloperRendered, + /// Choice screen is rendered by Google Play. + GoogleRendered +} + +public sealed class BillingChoiceScreenTypeAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["unspecified"] = BillingChoiceScreenTypeAndroid.Unspecified, + ["UNSPECIFIED"] = BillingChoiceScreenTypeAndroid.Unspecified, + ["developer-rendered"] = BillingChoiceScreenTypeAndroid.DeveloperRendered, + ["DEVELOPER_RENDERED"] = BillingChoiceScreenTypeAndroid.DeveloperRendered, + ["google-rendered"] = BillingChoiceScreenTypeAndroid.GoogleRendered, + ["GOOGLE_RENDERED"] = BillingChoiceScreenTypeAndroid.GoogleRendered, + }; + + private static readonly Dictionary _toString = new() + { + [BillingChoiceScreenTypeAndroid.Unspecified] = "unspecified", + [BillingChoiceScreenTypeAndroid.DeveloperRendered] = "developer-rendered", + [BillingChoiceScreenTypeAndroid.GoogleRendered] = "google-rendered", + }; + + public override BillingChoiceScreenTypeAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown BillingChoiceScreenTypeAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, BillingChoiceScreenTypeAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(BillingChoiceScreenTypeAndroid value) => _toString[value]; + internal static BillingChoiceScreenTypeAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown BillingChoiceScreenTypeAndroid value: {value}"); +} + +public static class BillingChoiceScreenTypeAndroidExtensions +{ + public static string ToJson(this BillingChoiceScreenTypeAndroid value) => BillingChoiceScreenTypeAndroidJsonConverter.ToRawString(value); + public static BillingChoiceScreenTypeAndroid FromJson(string value) => BillingChoiceScreenTypeAndroidJsonConverter.FromRawString(value); +} + +/// Billing program types for Google Play Billing Programs (Android) +/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, +/// BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (requires Play Billing 9.1.0+). [JsonConverter(typeof(BillingProgramAndroidJsonConverter))] public enum BillingProgramAndroid { @@ -101,7 +213,11 @@ public enum BillingProgramAndroid /// Allows presenting a side-by-side choice between Google Play Billing and developer's external payment option. /// Users can choose to complete the purchase on the developer's website. /// Available in Google Play Billing Library 8.3.0+ - ExternalPayments + ExternalPayments, + /// Billing Choice program. + /// Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + BillingChoice } public sealed class BillingProgramAndroidJsonConverter : JsonConverter @@ -118,6 +234,8 @@ public sealed class BillingProgramAndroidJsonConverter : JsonConverter _toString = new() @@ -127,6 +245,7 @@ public sealed class BillingProgramAndroidJsonConverter : JsonConverter DeveloperBillingLaunchModeAndroidJsonConverter.FromRawString(value); } +/// Developer-provided billing destination type for Billing Program reporting details (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +[JsonConverter(typeof(DeveloperBillingTypeAndroidJsonConverter))] +public enum DeveloperBillingTypeAndroid +{ + /// Unspecified developer billing type. Do not use. + DeveloperBillingTypeUnspecified, + /// Developer-provided billing via native in-app experience. + InApp, + /// Developer-provided billing via external link or embedded web browsing. + ExternalLink +} + +public sealed class DeveloperBillingTypeAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["developer-billing-type-unspecified"] = DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified, + ["DEVELOPER_BILLING_TYPE_UNSPECIFIED"] = DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified, + ["in-app"] = DeveloperBillingTypeAndroid.InApp, + ["IN_APP"] = DeveloperBillingTypeAndroid.InApp, + ["external-link"] = DeveloperBillingTypeAndroid.ExternalLink, + ["EXTERNAL_LINK"] = DeveloperBillingTypeAndroid.ExternalLink, + }; + + private static readonly Dictionary _toString = new() + { + [DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified] = "developer-billing-type-unspecified", + [DeveloperBillingTypeAndroid.InApp] = "in-app", + [DeveloperBillingTypeAndroid.ExternalLink] = "external-link", + }; + + public override DeveloperBillingTypeAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown DeveloperBillingTypeAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, DeveloperBillingTypeAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(DeveloperBillingTypeAndroid value) => _toString[value]; + internal static DeveloperBillingTypeAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown DeveloperBillingTypeAndroid value: {value}"); +} + +public static class DeveloperBillingTypeAndroidExtensions +{ + public static string ToJson(this DeveloperBillingTypeAndroid value) => DeveloperBillingTypeAndroidJsonConverter.ToRawString(value); + public static DeveloperBillingTypeAndroid FromJson(string value) => DeveloperBillingTypeAndroidJsonConverter.FromRawString(value); +} + /// Discount offer type enumeration. /// Categorizes the type of discount or promotional offer. [JsonConverter(typeof(DiscountOfferTypeJsonConverter))] @@ -774,8 +948,9 @@ public enum IapEvent PurchaseError, PromotedProductIOS, UserChoiceBillingAndroid, - /// Fired when user selects developer-provided billing option in external payments flow. - /// Available on Android with Google Play Billing Library 8.3.0+ + /// Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + /// developer billing selections on Android. Billing Choice is available in + /// OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). DeveloperProvidedBillingAndroid, /// Fired when an active subscription enters a billing-issue state that requires user attention. /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and @@ -1035,6 +1210,108 @@ public static class IapStoreExtensions public static IapStore FromJson(string value) => IapStoreJsonConverter.FromRawString(value); } +/// High-level in-app message category (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +[JsonConverter(typeof(InAppMessageCategoryAndroidJsonConverter))] +public enum InAppMessageCategoryAndroid +{ + /// Unknown in-app message category. + UnknownInAppMessageCategoryId, + /// Transactional billing messages, such as payment issues or pending price-change confirmations. + Transactional +} + +public sealed class InAppMessageCategoryAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["unknown-in-app-message-category-id"] = InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId, + ["UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID"] = InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId, + ["transactional"] = InAppMessageCategoryAndroid.Transactional, + ["TRANSACTIONAL"] = InAppMessageCategoryAndroid.Transactional, + }; + + private static readonly Dictionary _toString = new() + { + [InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId] = "unknown-in-app-message-category-id", + [InAppMessageCategoryAndroid.Transactional] = "transactional", + }; + + public override InAppMessageCategoryAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown InAppMessageCategoryAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, InAppMessageCategoryAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(InAppMessageCategoryAndroid value) => _toString[value]; + internal static InAppMessageCategoryAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown InAppMessageCategoryAndroid value: {value}"); +} + +public static class InAppMessageCategoryAndroidExtensions +{ + public static string ToJson(this InAppMessageCategoryAndroid value) => InAppMessageCategoryAndroidJsonConverter.ToRawString(value); + public static InAppMessageCategoryAndroid FromJson(string value) => InAppMessageCategoryAndroidJsonConverter.FromRawString(value); +} + +/// Response code from Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +[JsonConverter(typeof(InAppMessageResponseCodeAndroidJsonConverter))] +public enum InAppMessageResponseCodeAndroid +{ + /// Flow finished and no developer action is needed. + NoActionNeeded, + /// Subscription status changed and the purchase token should be checked. + SubscriptionStatusUpdated +} + +public sealed class InAppMessageResponseCodeAndroidJsonConverter : JsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["no-action-needed"] = InAppMessageResponseCodeAndroid.NoActionNeeded, + ["NO_ACTION_NEEDED"] = InAppMessageResponseCodeAndroid.NoActionNeeded, + ["subscription-status-updated"] = InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated, + ["SUBSCRIPTION_STATUS_UPDATED"] = InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated, + }; + + private static readonly Dictionary _toString = new() + { + [InAppMessageResponseCodeAndroid.NoActionNeeded] = "no-action-needed", + [InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated] = "subscription-status-updated", + }; + + public override InAppMessageResponseCodeAndroid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown InAppMessageResponseCodeAndroid value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, InAppMessageResponseCodeAndroid value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(InAppMessageResponseCodeAndroid value) => _toString[value]; + internal static InAppMessageResponseCodeAndroid FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown InAppMessageResponseCodeAndroid value: {value}"); +} + +public static class InAppMessageResponseCodeAndroidExtensions +{ + public static string ToJson(this InAppMessageResponseCodeAndroid value) => InAppMessageResponseCodeAndroidJsonConverter.ToRawString(value); + public static InAppMessageResponseCodeAndroid FromJson(string value) => InAppMessageResponseCodeAndroidJsonConverter.FromRawString(value); +} + /// Payment mode for subscription offers. /// Determines how the user pays during the offer period. [JsonConverter(typeof(PaymentModeJsonConverter))] @@ -2305,6 +2582,7 @@ public interface PurchaseCommon int Quantity { get; } /// Store where purchase was made IapStore Store { get; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. double TransactionDate { get; } } @@ -2375,6 +2653,7 @@ public sealed record ActiveSubscription /// pending upgrades/downgrades, and auto-renewal preferences. [JsonPropertyName("renewalInfoIOS")] public RenewalInfoIOS? RenewalInfoIOS { get; init; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] public required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] @@ -2479,6 +2758,18 @@ public sealed record AppTransaction public required double SignedDate { get; init; } } +/// Display information for developer-rendered Billing Choice screens (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public sealed record BillingChoiceInfoAndroid +{ + /// URL for the Play Billing choice image matching the requested layout. + [JsonPropertyName("playBillingChoiceImageUrl")] + public required string PlayBillingChoiceImageUrl { get; init; } + /// Play Loyalty information for the user. + [JsonPropertyName("playBillingLoyaltyInfo")] + public string? PlayBillingLoyaltyInfo { get; init; } +} + /// Result of checking billing program availability (Android) /// Available in Google Play Billing Library 8.2.0+ public sealed record BillingProgramAvailabilityResultAndroid @@ -2486,9 +2777,18 @@ public sealed record BillingProgramAvailabilityResultAndroid /// The billing program that was checked [JsonPropertyName("billingProgram")] public required BillingProgramAndroid BillingProgram { get; init; } + /// Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + [JsonPropertyName("choiceScreenType")] + public BillingChoiceScreenTypeAndroid? ChoiceScreenType { get; init; } /// Whether the billing program is available for the user [JsonPropertyName("isAvailable")] public required bool IsAvailable { get; init; } + /// Whether external-link payment is available for Billing Choice. + /// Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + [JsonPropertyName("isExternalLinkAvailable")] + public bool? IsExternalLinkAvailable { get; init; } } /// Reporting details for transactions made outside of Google Play Billing (Android) @@ -2527,10 +2827,34 @@ public sealed record BillingResultAndroid public sealed record DeveloperProvidedBillingDetailsAndroid { /// External transaction token used to report transactions made through developer billing. - /// This token must be used when reporting the external transaction to Google Play. - /// Must be reported within 24 hours of the transaction. + /// Nullable for flows such as external payments where no token is returned. [JsonPropertyName("externalTransactionToken")] - public required string ExternalTransactionToken { get; init; } + public string? ExternalTransactionToken { get; init; } + /// URI to launch for an external-link Billing Choice flow, when provided by + /// Google Play. + [JsonPropertyName("linkUri")] + public string? LinkUri { get; init; } + /// Original external transaction ID when replacing a subscription that was + /// purchased through developer billing. + [JsonPropertyName("originalExternalTransactionId")] + public string? OriginalExternalTransactionId { get; init; } + /// Products selected for the developer billing flow. + [JsonPropertyName("products")] + public required IReadOnlyList Products { get; init; } +} + +/// Product selected for developer-provided billing (Android 9.0+). +public sealed record DeveloperProvidedBillingProductAndroid +{ + /// Product identifier. + [JsonPropertyName("id")] + public required string Id { get; init; } + /// Subscription offer token, when applicable. + [JsonPropertyName("offerToken")] + public string? OfferToken { get; init; } + /// Google Play product type (in-app or subscription). + [JsonPropertyName("type")] + public required ProductType Type { get; init; } } /// Discount amount details for one-time purchase offers (Android) @@ -2764,6 +3088,19 @@ public sealed record FetchProductsResultProducts(IReadOnlyList? Value) public sealed record FetchProductsResultSubscriptions(IReadOnlyList? Value) : FetchProductsResult; +/// Result from showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public sealed record InAppMessageResultAndroid +{ + /// Purchase token returned when a subscription status changed. + [JsonPropertyName("purchaseToken")] + public string? PurchaseToken { get; init; } + /// Response code for the in-app messaging flow. + [JsonPropertyName("responseCode")] + public required InAppMessageResponseCodeAndroid ResponseCode { get; init; } +} + /// Installment plan details for subscription offers (Android) /// Contains information about the installment plan commitment. /// Available in Google Play Billing Library 7.0+ @@ -2874,7 +3211,7 @@ public sealed record ProductAndroid : Product, ProductCommon [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = IapPlatform.Android; + public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; [JsonPropertyName("price")] public double? Price { get; init; } /// Product-level status code indicating fetch result (Android 8.0+) @@ -2895,7 +3232,7 @@ public sealed record ProductAndroid : Product, ProductCommon [JsonPropertyName("title")] public required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = ProductType.InApp; + public ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; } /// One-time purchase offer details (Android). @@ -2968,7 +3305,7 @@ public sealed record ProductIOS : Product, ProductCommon [JsonPropertyName("jsonRepresentationIOS")] public required string JsonRepresentationIOS { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = IapPlatform.IOS; + public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; [JsonPropertyName("price")] public double? Price { get; init; } /// iOS 26.4+ subscription pricing terms, including billing plan metadata for @@ -2987,7 +3324,7 @@ public sealed record ProductIOS : Product, ProductCommon [JsonPropertyName("title")] public required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = ProductType.InApp; + public ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; [JsonPropertyName("typeIOS")] public required ProductTypeIOS TypeIOS { get; init; } } @@ -3019,7 +3356,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo [JsonPropertyName("oneTimePurchaseOfferDetailsAndroid")] public IReadOnlyList? OneTimePurchaseOfferDetailsAndroid { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = IapPlatform.Android; + public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; [JsonPropertyName("price")] public double? Price { get; init; } /// Product-level status code indicating fetch result (Android 8.0+) @@ -3040,7 +3377,7 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo [JsonPropertyName("title")] public required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = ProductType.Subs; + public ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; } /// Subscription offer details (Android). @@ -3099,7 +3436,7 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("jsonRepresentationIOS")] public required string JsonRepresentationIOS { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = IapPlatform.IOS; + public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; [JsonPropertyName("price")] public double? Price { get; init; } /// iOS 26.4+ subscription pricing terms, including billing plan metadata for @@ -3124,7 +3461,7 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("title")] public required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = ProductType.Subs; + public ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; [JsonPropertyName("typeIOS")] public required ProductTypeIOS TypeIOS { get; init; } } @@ -3181,6 +3518,7 @@ public sealed record PurchaseAndroid : Purchase, PurchaseCommon /// Store where purchase was made [JsonPropertyName("store")] public required IapStore Store { get; init; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] public required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] @@ -3281,6 +3619,7 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon public string? StorefrontCountryCodeIOS { get; init; } [JsonPropertyName("subscriptionGroupIdIOS")] public string? SubscriptionGroupIdIOS { get; init; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] public required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] @@ -3774,6 +4113,18 @@ public sealed record AndroidSubscriptionOfferInput public required string OfferToken { get; init; } } +/// Parameters for showing a billing program information dialog (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public sealed record BillingProgramInformationDialogParamsAndroid +{ + /// Billing program. Currently only BILLING_CHOICE is supported. + [JsonPropertyName("billingProgram")] + public BillingProgramAndroid BillingProgram { get; init; } = global::OpenIap.BillingProgramAndroid.BillingChoice; + /// External transaction token returned by the Billing Choice reporting-details flow. + [JsonPropertyName("externalTransactionToken")] + public required string ExternalTransactionToken { get; init; } +} + public sealed record DeepLinkOptions { /// Android SKU to open (required on Android) @@ -3784,20 +4135,28 @@ public sealed record DeepLinkOptions public string? PackageNameAndroid { get; init; } } -/// Parameters for developer billing option in purchase flow (Android) -/// Used with BillingFlowParams to enable external payments flow -/// Available in Google Play Billing Library 8.3.0+ +/// Parameters for a developer billing option in a purchase flow (Android). +/// Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice +/// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). +/// Only billingProgram is required; link fields are used when the selected program +/// links outside the app. public sealed record DeveloperBillingOptionParamsAndroid { - /// The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + /// The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. [JsonPropertyName("billingProgram")] public required BillingProgramAndroid BillingProgram { get; init; } - /// The URI where the external payment will be processed + /// The URI where the external payment will be processed. + /// Required only when the selected billing program links outside the app. [JsonPropertyName("linkUri")] - public required string LinkUri { get; init; } - /// The launch mode for the external payment link + public string? LinkUri { get; init; } + /// The launch mode for the external payment link. + /// Required only when the selected billing program links outside the app. [JsonPropertyName("launchMode")] - public required DeveloperBillingLaunchModeAndroid LaunchMode { get; init; } + public DeveloperBillingLaunchModeAndroid? LaunchMode { get; init; } + /// A pre-generated external transaction token for a Billing Choice external-link + /// flow. Omit it when Google Play should provide the token in the callback. + [JsonPropertyName("externalTransactionToken")] + public string? ExternalTransactionToken { get; init; } } public sealed record DiscountOfferInputIOS @@ -3819,6 +4178,31 @@ public sealed record DiscountOfferInputIOS public required double Timestamp { get; init; } } +/// Parameters for fetching Billing Choice display information (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public sealed record GetBillingChoiceInfoParamsAndroid +{ + /// Billing program. Currently only BILLING_CHOICE is supported. + [JsonPropertyName("billingProgram")] + public BillingProgramAndroid BillingProgram { get; init; } = global::OpenIap.BillingProgramAndroid.BillingChoice; + /// Desired Play Billing choice image layout. + [JsonPropertyName("playBillingChoiceImageLayout")] + public BillingChoiceImageLayoutAndroid PlayBillingChoiceImageLayout { get; init; } = global::OpenIap.BillingChoiceImageLayoutAndroid.RectangularFourByOne; + /// BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + [JsonPropertyName("userLocale")] + public string? UserLocale { get; init; } +} + +/// Parameters for showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public sealed record InAppMessageParamsAndroid +{ + /// In-app message categories to show. Defaults to transactional messages. + [JsonPropertyName("categories")] + public IReadOnlyList? Categories { get; init; } = new List { global::OpenIap.InAppMessageCategoryAndroid.Transactional }; +} + /// Connection initialization configuration public sealed record InitConnectionConfig { @@ -3834,16 +4218,28 @@ public sealed record InitConnectionConfig /// - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) /// - EXTERNAL_OFFER: External offers for digital content (8.2.0+) /// - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + /// - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + /// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) [JsonPropertyName("enableBillingProgramAndroid")] public BillingProgramAndroid? EnableBillingProgramAndroid { get; init; } + /// Billing Choice renderer configured in Play Console. Available in OpenIAP + /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + /// can emit the selection event. DEVELOPER_RENDERED omits that listener so the + /// app can render its own choice screen and use the reporting/dialog/link APIs. + /// Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + /// Defaults to GOOGLE_RENDERED. + [JsonPropertyName("billingChoiceScreenTypeAndroid")] + public BillingChoiceScreenTypeAndroid? BillingChoiceScreenTypeAndroid { get; init; } = global::OpenIap.BillingChoiceScreenTypeAndroid.GoogleRendered; } /// Parameters for launching an external link (Android) -/// Used with launchExternalLink to initiate external offer or app install flows +/// Used with launchExternalLink to initiate external offer, app install, or +/// developer-rendered Billing Choice flows /// Available in Google Play Billing Library 8.2.0+ public sealed record LaunchExternalLinkParamsAndroid { - /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + /// The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) [JsonPropertyName("billingProgram")] public required BillingProgramAndroid BillingProgram { get; init; } /// The external link launch mode @@ -3855,6 +4251,11 @@ public sealed record LaunchExternalLinkParamsAndroid /// The URI where the content will be accessed from [JsonPropertyName("linkUri")] public required string LinkUri { get; init; } + /// External transaction token for a developer-rendered Billing Choice external-link + /// flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + [JsonPropertyName("externalTransactionToken")] + public string? ExternalTransactionToken { get; init; } } public sealed record ProductRequest @@ -3862,7 +4263,7 @@ public sealed record ProductRequest [JsonPropertyName("skus")] public required IReadOnlyList Skus { get; init; } [JsonPropertyName("type")] - public ProductQueryType? Type { get; init; } + public ProductQueryType? Type { get; init; } = global::OpenIap.ProductQueryType.InApp; } /// JWS promotional offer input for iOS 15+ (StoreKit 2, WWDC 2025). @@ -3930,9 +4331,9 @@ public sealed record RequestPurchaseAndroidProps /// to apply a discount offer to the purchase. [JsonPropertyName("offerToken")] public string? OfferToken { get; init; } - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). [JsonPropertyName("developerBillingOption")] public DeveloperBillingOptionParamsAndroid? DeveloperBillingOption { get; init; } } @@ -4019,6 +4420,11 @@ public sealed record RequestSubscriptionAndroidProps /// Purchase token for upgrades/downgrades [JsonPropertyName("purchaseToken")] public string? PurchaseToken { get; init; } + /// Original external transaction ID for replacing a subscription that was + /// purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). + [JsonPropertyName("originalExternalTransactionId")] + public string? OriginalExternalTransactionId { get; init; } /// Replacement mode for subscription changes /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) [JsonPropertyName("replacementMode")] @@ -4030,9 +4436,9 @@ public sealed record RequestSubscriptionAndroidProps /// Use this instead of replacementMode for item-level replacement [JsonPropertyName("subscriptionProductReplacementParams")] public SubscriptionProductReplacementParamsAndroid? SubscriptionProductReplacementParams { get; init; } - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). [JsonPropertyName("developerBillingOption")] public DeveloperBillingOptionParamsAndroid? DeveloperBillingOption { get; init; } } @@ -4270,7 +4676,6 @@ public interface MutationResolver Task BeginRefundRequestIOSAsync(string sku); /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -4287,7 +4692,6 @@ public interface MutationResolver /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. /// Token must be reported to Google Play backend within 24 hours. - /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -4295,11 +4699,14 @@ public interface MutationResolver /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. - /// /// Returns external transaction token needed for reporting external transactions. + /// developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + /// native Android defaults it to IN_APP. + /// The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android - Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program); + Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program, DeveloperBillingTypeAndroid? developerBillingType = null); /// Open the platform's subscription management UI. /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions @@ -4319,16 +4726,17 @@ public interface MutationResolver /// Check whether a billing program (e.g., External Payments) is available for the current user. /// Replaces the deprecated isExternalOfferAvailableAsync API. - /// /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android Task IsBillingProgramAvailableAndroidAsync(BillingProgramAndroid program); - /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + /// including developer-rendered Billing Choice external-link flows. + /// Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. - /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -4366,18 +4774,32 @@ public interface MutationResolver /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. - /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android Task ShowAlternativeBillingDialogAndroidAsync(); + /// Show Google's mandatory information dialog before a developer-rendered, + /// in-app Billing Choice screen. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + Task ShowBillingProgramInformationDialogAndroidAsync(BillingProgramInformationDialogParamsAndroid @params); + /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios Task ShowExternalPurchaseCustomLinkNoticeIOSAsync(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); + /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + /// (upstream API available since Play Billing 4.1.0). + /// Returns a response code and, when the subscription status changes, the related purchase token. + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + Task ShowInAppMessagesAndroidAsync(InAppMessageParamsAndroid? @params = null); + /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios Task> ShowManageSubscriptionsIOSAsync(); @@ -4440,6 +4862,12 @@ public interface QueryResolver /// See: https://openiap.dev/docs/apis/get-available-purchases Task> GetAvailablePurchasesAsync(PurchaseOptions? options = null); + /// Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client is not ready. + /// See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + Task GetBillingChoiceInfoAndroidAsync(GetBillingChoiceInfoParamsAndroid @params); + /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) @@ -4504,11 +4932,11 @@ public interface QueryResolver /// GraphQL root subscription operations. public interface SubscriptionResolver { - /// Fires when a user selects developer billing in the External Payments flow (Android only) - /// Triggered when the user chooses to pay via the developer's external payment option - /// instead of Google Play Billing in the side-by-side choice dialog. - /// Contains the externalTransactionToken needed to report the transaction. - /// Available in Google Play Billing Library 8.3.0+ + /// Fires when a user selects developer billing in an External Payments or + /// Billing Choice flow (Android only). The payload can contain an external + /// transaction token, link URI, original transaction ID, and selected products. + /// Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). Task DeveloperProvidedBillingAndroidAsync(); /// Fires when the App Store surfaces a promoted product (iOS only) diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 7cf7a7309..4ef581957 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -48,8 +48,76 @@ public enum class AlternativeBillingModeAndroid(val rawValue: String) { } /** - * Billing program types for external content links, external offers, and external payments (Android) - * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 + * Play Billing choice image layout (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class BillingChoiceImageLayoutAndroid(val rawValue: String) { + /** + * Rectangular image with a 4:1 aspect ratio. + */ + RectangularFourByOne("rectangular-four-by-one"), + /** + * Rectangular image with a 3:1 aspect ratio. + */ + RectangularThreeByOne("rectangular-three-by-one"), + /** + * Rectangular image with a 2:2 aspect ratio. + */ + RectangularTwoByTwo("rectangular-two-by-two") + + companion object { + fun fromJson(value: String): BillingChoiceImageLayoutAndroid = when (value) { + "rectangular-four-by-one" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne + "RECTANGULAR_FOUR_BY_ONE" -> BillingChoiceImageLayoutAndroid.RectangularFourByOne + "rectangular-three-by-one" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne + "RECTANGULAR_THREE_BY_ONE" -> BillingChoiceImageLayoutAndroid.RectangularThreeByOne + "rectangular-two-by-two" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo + "RECTANGULAR_TWO_BY_TWO" -> BillingChoiceImageLayoutAndroid.RectangularTwoByTwo + else -> throw IllegalArgumentException("Unknown BillingChoiceImageLayoutAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Choice screen renderer for Billing Choice availability (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class BillingChoiceScreenTypeAndroid(val rawValue: String) { + /** + * Unspecified choice screen type. + */ + Unspecified("unspecified"), + /** + * Choice screen is rendered by the developer app. + */ + DeveloperRendered("developer-rendered"), + /** + * Choice screen is rendered by Google Play. + */ + GoogleRendered("google-rendered") + + companion object { + fun fromJson(value: String): BillingChoiceScreenTypeAndroid = when (value) { + "unspecified" -> BillingChoiceScreenTypeAndroid.Unspecified + "UNSPECIFIED" -> BillingChoiceScreenTypeAndroid.Unspecified + "developer-rendered" -> BillingChoiceScreenTypeAndroid.DeveloperRendered + "DEVELOPER_RENDERED" -> BillingChoiceScreenTypeAndroid.DeveloperRendered + "google-rendered" -> BillingChoiceScreenTypeAndroid.GoogleRendered + "GOOGLE_RENDERED" -> BillingChoiceScreenTypeAndroid.GoogleRendered + else -> throw IllegalArgumentException("Unknown BillingChoiceScreenTypeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Billing program types for Google Play Billing Programs (Android) + * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, + * BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ public enum class BillingProgramAndroid(val rawValue: String) { /** @@ -80,7 +148,13 @@ public enum class BillingProgramAndroid(val rawValue: String) { * Users can choose to complete the purchase on the developer's website. * Available in Google Play Billing Library 8.3.0+ */ - ExternalPayments("external-payments") + ExternalPayments("external-payments"), + /** + * Billing Choice program. + * Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + BillingChoice("billing-choice") companion object { fun fromJson(value: String): BillingProgramAndroid = when (value) { @@ -94,6 +168,8 @@ public enum class BillingProgramAndroid(val rawValue: String) { "EXTERNAL_OFFER" -> BillingProgramAndroid.ExternalOffer "external-payments" -> BillingProgramAndroid.ExternalPayments "EXTERNAL_PAYMENTS" -> BillingProgramAndroid.ExternalPayments + "billing-choice" -> BillingProgramAndroid.BillingChoice + "BILLING_CHOICE" -> BillingProgramAndroid.BillingChoice else -> throw IllegalArgumentException("Unknown BillingProgramAndroid value: $value") } } @@ -137,6 +213,39 @@ public enum class DeveloperBillingLaunchModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Developer-provided billing destination type for Billing Program reporting details (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public enum class DeveloperBillingTypeAndroid(val rawValue: String) { + /** + * Unspecified developer billing type. Do not use. + */ + DeveloperBillingTypeUnspecified("developer-billing-type-unspecified"), + /** + * Developer-provided billing via native in-app experience. + */ + InApp("in-app"), + /** + * Developer-provided billing via external link or embedded web browsing. + */ + ExternalLink("external-link") + + companion object { + fun fromJson(value: String): DeveloperBillingTypeAndroid = when (value) { + "developer-billing-type-unspecified" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified + "DEVELOPER_BILLING_TYPE_UNSPECIFIED" -> DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified + "in-app" -> DeveloperBillingTypeAndroid.InApp + "IN_APP" -> DeveloperBillingTypeAndroid.InApp + "external-link" -> DeveloperBillingTypeAndroid.ExternalLink + "EXTERNAL_LINK" -> DeveloperBillingTypeAndroid.ExternalLink + else -> throw IllegalArgumentException("Unknown DeveloperBillingTypeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + /** * Discount offer type enumeration. * Categorizes the type of discount or promotional offer. @@ -498,8 +607,9 @@ public enum class IapEvent(val rawValue: String) { PromotedProductIos("promoted-product-ios"), UserChoiceBillingAndroid("user-choice-billing-android"), /** - * Fired when user selects developer-provided billing option in external payments flow. - * Available on Android with Google Play Billing Library 8.3.0+ + * Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + * developer billing selections on Android. Billing Choice is available in + * OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ DeveloperProvidedBillingAndroid("developer-provided-billing-android"), /** @@ -654,6 +764,62 @@ public enum class IapStore(val rawValue: String) { fun toJson(): String = rawValue } +/** + * High-level in-app message category (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public enum class InAppMessageCategoryAndroid(val rawValue: String) { + /** + * Unknown in-app message category. + */ + UnknownInAppMessageCategoryId("unknown-in-app-message-category-id"), + /** + * Transactional billing messages, such as payment issues or pending price-change confirmations. + */ + Transactional("transactional") + + companion object { + fun fromJson(value: String): InAppMessageCategoryAndroid = when (value) { + "unknown-in-app-message-category-id" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId + "UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID" -> InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId + "transactional" -> InAppMessageCategoryAndroid.Transactional + "TRANSACTIONAL" -> InAppMessageCategoryAndroid.Transactional + else -> throw IllegalArgumentException("Unknown InAppMessageCategoryAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Response code from Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public enum class InAppMessageResponseCodeAndroid(val rawValue: String) { + /** + * Flow finished and no developer action is needed. + */ + NoActionNeeded("no-action-needed"), + /** + * Subscription status changed and the purchase token should be checked. + */ + SubscriptionStatusUpdated("subscription-status-updated") + + companion object { + fun fromJson(value: String): InAppMessageResponseCodeAndroid = when (value) { + "no-action-needed" -> InAppMessageResponseCodeAndroid.NoActionNeeded + "NO_ACTION_NEEDED" -> InAppMessageResponseCodeAndroid.NoActionNeeded + "subscription-status-updated" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + "SUBSCRIPTION_STATUS_UPDATED" -> InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated + else -> throw IllegalArgumentException("Unknown InAppMessageResponseCodeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + /** * Payment mode for subscription offers. * Determines how the user pays during the offer period. @@ -1414,6 +1580,9 @@ public interface PurchaseCommon { * Store where purchase was made */ val store: IapStore + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ val transactionDate: Double } @@ -1444,6 +1613,9 @@ public data class ActiveSubscription( * pending upgrades/downgrades, and auto-renewal preferences. */ val renewalInfoIOS: RenewalInfoIOS? = null, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ val transactionDate: Double, val transactionId: String, /** @@ -1702,6 +1874,37 @@ public data class AppTransaction( ) } +/** + * Display information for developer-rendered Billing Choice screens (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class BillingChoiceInfoAndroid( + /** + * URL for the Play Billing choice image matching the requested layout. + */ + val playBillingChoiceImageUrl: String, + /** + * Play Loyalty information for the user. + */ + val playBillingLoyaltyInfo: String? = null +) { + + companion object { + fun fromJson(json: Map): BillingChoiceInfoAndroid { + return BillingChoiceInfoAndroid( + playBillingChoiceImageUrl = json["playBillingChoiceImageUrl"] as? String ?: "", + playBillingLoyaltyInfo = json["playBillingLoyaltyInfo"] as? String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "BillingChoiceInfoAndroid", + "playBillingChoiceImageUrl" to playBillingChoiceImageUrl, + "playBillingLoyaltyInfo" to playBillingLoyaltyInfo, + ) +} + /** * Result of checking billing program availability (Android) * Available in Google Play Billing Library 8.2.0+ @@ -1711,17 +1914,30 @@ public data class BillingProgramAvailabilityResultAndroid( * The billing program that was checked */ val billingProgram: BillingProgramAndroid, + /** + * Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + val choiceScreenType: BillingChoiceScreenTypeAndroid? = null, /** * Whether the billing program is available for the user */ - val isAvailable: Boolean + val isAvailable: Boolean, + /** + * Whether external-link payment is available for Billing Choice. + * Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + val isExternalLinkAvailable: Boolean? = null ) { companion object { fun fromJson(json: Map): BillingProgramAvailabilityResultAndroid { return BillingProgramAvailabilityResultAndroid( billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified, + choiceScreenType = (json["choiceScreenType"] as? String)?.let { BillingChoiceScreenTypeAndroid.fromJson(it) }, isAvailable = json["isAvailable"] as? Boolean ?: false, + isExternalLinkAvailable = json["isExternalLinkAvailable"] as? Boolean, ) } } @@ -1729,7 +1945,9 @@ public data class BillingProgramAvailabilityResultAndroid( fun toJson(): Map = mapOf( "__typename" to "BillingProgramAvailabilityResultAndroid", "billingProgram" to billingProgram.toJson(), + "choiceScreenType" to choiceScreenType?.toJson(), "isAvailable" to isAvailable, + "isExternalLinkAvailable" to isExternalLinkAvailable, ) } @@ -1812,16 +2030,32 @@ public data class BillingResultAndroid( public data class DeveloperProvidedBillingDetailsAndroid( /** * External transaction token used to report transactions made through developer billing. - * This token must be used when reporting the external transaction to Google Play. - * Must be reported within 24 hours of the transaction. + * Nullable for flows such as external payments where no token is returned. */ - val externalTransactionToken: String + val externalTransactionToken: String? = null, + /** + * URI to launch for an external-link Billing Choice flow, when provided by + * Google Play. + */ + val linkUri: String? = null, + /** + * Original external transaction ID when replacing a subscription that was + * purchased through developer billing. + */ + val originalExternalTransactionId: String? = null, + /** + * Products selected for the developer billing flow. + */ + val products: List ) { companion object { fun fromJson(json: Map): DeveloperProvidedBillingDetailsAndroid { return DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken = json["externalTransactionToken"] as? String ?: "", + externalTransactionToken = json["externalTransactionToken"] as? String, + linkUri = json["linkUri"] as? String, + originalExternalTransactionId = json["originalExternalTransactionId"] as? String, + products = (json["products"] as? List<*>)?.mapNotNull { (it as? Map)?.let { DeveloperProvidedBillingProductAndroid.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for DeveloperProvidedBillingProductAndroid") } ?: emptyList(), ) } } @@ -1829,6 +2063,45 @@ public data class DeveloperProvidedBillingDetailsAndroid( fun toJson(): Map = mapOf( "__typename" to "DeveloperProvidedBillingDetailsAndroid", "externalTransactionToken" to externalTransactionToken, + "linkUri" to linkUri, + "originalExternalTransactionId" to originalExternalTransactionId, + "products" to products.map { it.toJson() }, + ) +} + +/** + * Product selected for developer-provided billing (Android 9.0+). + */ +public data class DeveloperProvidedBillingProductAndroid( + /** + * Product identifier. + */ + val id: String, + /** + * Subscription offer token, when applicable. + */ + val offerToken: String? = null, + /** + * Google Play product type (in-app or subscription). + */ + val type: ProductType +) { + + companion object { + fun fromJson(json: Map): DeveloperProvidedBillingProductAndroid { + return DeveloperProvidedBillingProductAndroid( + id = json["id"] as? String ?: "", + offerToken = json["offerToken"] as? String, + type = (json["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "DeveloperProvidedBillingProductAndroid", + "id" to id, + "offerToken" to offerToken, + "type" to type.toJson(), ) } @@ -2336,6 +2609,38 @@ public data class FetchProductsResultProducts(val value: List?) : Fetch public data class FetchProductsResultSubscriptions(val value: List?) : FetchProductsResult +/** + * Result from showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public data class InAppMessageResultAndroid( + /** + * Purchase token returned when a subscription status changed. + */ + val purchaseToken: String? = null, + /** + * Response code for the in-app messaging flow. + */ + val responseCode: InAppMessageResponseCodeAndroid +) { + + companion object { + fun fromJson(json: Map): InAppMessageResultAndroid { + return InAppMessageResultAndroid( + purchaseToken = json["purchaseToken"] as? String, + responseCode = (json["responseCode"] as? String)?.let { InAppMessageResponseCodeAndroid.fromJson(it) } ?: InAppMessageResponseCodeAndroid.NoActionNeeded, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "InAppMessageResultAndroid", + "purchaseToken" to purchaseToken, + "responseCode" to responseCode.toJson(), + ) +} + /** * Installment plan details for subscription offers (Android) * Contains information about the installment plan commitment. @@ -2582,7 +2887,7 @@ public data class ProductAndroid( oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, price = (json["price"] as? Number)?.toDouble(), - productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) }, + productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { runCatching { ProductStatusAndroid.fromJson(it) }.getOrNull() ?: ProductStatusAndroid.Unknown }, subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") }, subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") }, title = json["title"] as? String ?: "", @@ -2844,7 +3149,7 @@ public data class ProductSubscriptionAndroid( oneTimePurchaseOfferDetailsAndroid = (json["oneTimePurchaseOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductAndroidOneTimePurchaseOfferDetail.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductAndroidOneTimePurchaseOfferDetail") }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, price = (json["price"] as? Number)?.toDouble(), - productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { ProductStatusAndroid.fromJson(it) }, + productStatusAndroid = (json["productStatusAndroid"] as? String)?.let { runCatching { ProductStatusAndroid.fromJson(it) }.getOrNull() ?: ProductStatusAndroid.Unknown }, subscriptionOfferDetailsAndroid = (json["subscriptionOfferDetailsAndroid"] as? List<*>)?.mapNotNull { (it as? Map)?.let { ProductSubscriptionAndroidOfferDetails.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for ProductSubscriptionAndroidOfferDetails") } ?: emptyList(), subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") } ?: emptyList(), title = json["title"] as? String ?: "", @@ -3065,6 +3370,9 @@ public data class PurchaseAndroid( * Store where purchase was made */ override val store: IapStore, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ override val transactionDate: Double, val transactionId: String? = null ) : PurchaseCommon, Purchase { @@ -3087,11 +3395,11 @@ public data class PurchaseAndroid( pendingPurchaseUpdateAndroid = (json["pendingPurchaseUpdateAndroid"] as? Map)?.let { PendingPurchaseUpdateAndroid.fromJson(it) }, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, productId = json["productId"] as? String ?: "", - purchaseState = (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } ?: PurchaseState.Pending, + purchaseState = runCatching { (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } }.getOrNull() ?: PurchaseState.Unknown, purchaseToken = json["purchaseToken"] as? String, quantity = (json["quantity"] as? Number)?.toInt() ?: 0, signatureAndroid = json["signatureAndroid"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String, ) @@ -3139,7 +3447,7 @@ public data class PurchaseError( companion object { fun fromJson(json: Map): PurchaseError { return PurchaseError( - code = (json["code"] as? String)?.let { ErrorCode.fromJson(it) } ?: ErrorCode.Unknown, + code = runCatching { (json["code"] as? String)?.let { ErrorCode.fromJson(it) } }.getOrNull() ?: ErrorCode.Unknown, debugMessage = json["debugMessage"] as? String, isEmptyProductList = json["isEmptyProductList"] as? Boolean, message = json["message"] as? String ?: "", @@ -3212,6 +3520,9 @@ public data class PurchaseIOS( override val store: IapStore, val storefrontCountryCodeIOS: String? = null, val subscriptionGroupIdIOS: String? = null, + /** + * Unix timestamp in milliseconds since January 1, 1970 UTC. + */ override val transactionDate: Double, val transactionId: String, val transactionReasonIOS: String? = null, @@ -3224,7 +3535,7 @@ public data class PurchaseIOS( advancedCommerceInfoIOS = (json["advancedCommerceInfoIOS"] as? Map)?.let { AdvancedCommerceInfoIOS.fromJson(it) }, appAccountToken = json["appAccountToken"] as? String, appBundleIdIOS = json["appBundleIdIOS"] as? String, - billingPlanTypeIOS = (json["billingPlanTypeIOS"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) }, + billingPlanTypeIOS = (json["billingPlanTypeIOS"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown }, commitmentInfoIOS = (json["commitmentInfoIOS"] as? Map)?.let { TransactionCommitmentInfoIOS.fromJson(it) }, countryCodeIOS = json["countryCodeIOS"] as? String, currencyCodeIOS = json["currencyCodeIOS"] as? String, @@ -3242,7 +3553,7 @@ public data class PurchaseIOS( ownershipTypeIOS = json["ownershipTypeIOS"] as? String, platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, productId = json["productId"] as? String ?: "", - purchaseState = (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } ?: PurchaseState.Pending, + purchaseState = runCatching { (json["purchaseState"] as? String)?.let { PurchaseState.fromJson(it) } }.getOrNull() ?: PurchaseState.Unknown, purchaseToken = json["purchaseToken"] as? String, quantity = (json["quantity"] as? Number)?.toInt() ?: 0, quantityIOS = (json["quantityIOS"] as? Number)?.toInt(), @@ -3251,7 +3562,7 @@ public data class PurchaseIOS( renewalInfoIOS = (json["renewalInfoIOS"] as? Map)?.let { RenewalInfoIOS.fromJson(it) }, revocationDateIOS = (json["revocationDateIOS"] as? Number)?.toDouble(), revocationReasonIOS = json["revocationReasonIOS"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, storefrontCountryCodeIOS = json["storefrontCountryCodeIOS"] as? String, subscriptionGroupIdIOS = json["subscriptionGroupIdIOS"] as? String, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, @@ -3362,7 +3673,7 @@ public data class RenewalCommitmentInfoIOS( return RenewalCommitmentInfoIOS( commitmentAutoRenewProductId = json["commitmentAutoRenewProductId"] as? String ?: "", commitmentAutoRenewStatus = json["commitmentAutoRenewStatus"] as? Boolean ?: false, - commitmentRenewalBillingPlanType = (json["commitmentRenewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } ?: SubscriptionBillingPlanTypeIOS.Unknown, + commitmentRenewalBillingPlanType = runCatching { (json["commitmentRenewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown, commitmentRenewalDate = (json["commitmentRenewalDate"] as? Number)?.toDouble() ?: 0.0, commitmentRenewalPrice = (json["commitmentRenewalPrice"] as? Number)?.toDouble() ?: 0.0, ) @@ -3448,7 +3759,7 @@ public data class RenewalInfoIOS( jsonRepresentation = json["jsonRepresentation"] as? String, pendingUpgradeProductId = json["pendingUpgradeProductId"] as? String, priceIncreaseStatus = json["priceIncreaseStatus"] as? String, - renewalBillingPlanType = (json["renewalBillingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) }, + renewalBillingPlanType = (json["renewalBillingPlanType"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown }, renewalDate = (json["renewalDate"] as? Number)?.toDouble(), renewalOfferId = json["renewalOfferId"] as? String, renewalOfferType = json["renewalOfferType"] as? String, @@ -3529,8 +3840,8 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { return RequestVerifyPurchaseWithIapkitResult( isValid = json["isValid"] as? Boolean ?: false, - state = (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } ?: IapkitPurchaseState.Entitled, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + state = runCatching { (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } }.getOrNull() ?: IapkitPurchaseState.Unknown, + store = runCatching { (json["store"] as? String)?.let { IapStore.fromJson(it) } }.getOrNull() ?: IapStore.Unknown, ) } } @@ -3712,7 +4023,7 @@ public data class SubscriptionOffer( numberOfPeriodsIOS = (json["numberOfPeriodsIOS"] as? Number)?.toInt(), offerTagsAndroid = (json["offerTagsAndroid"] as? List<*>)?.mapNotNull { it as? String }, offerTokenAndroid = json["offerTokenAndroid"] as? String, - paymentMode = (json["paymentMode"] as? String)?.let { PaymentMode.fromJson(it) }, + paymentMode = (json["paymentMode"] as? String)?.let { runCatching { PaymentMode.fromJson(it) }.getOrNull() ?: PaymentMode.Unknown }, period = (json["period"] as? Map)?.let { SubscriptionPeriod.fromJson(it) }, periodCount = (json["periodCount"] as? Number)?.toInt(), price = (json["price"] as? Number)?.toDouble() ?: 0.0, @@ -3806,7 +4117,7 @@ public data class SubscriptionPeriod( companion object { fun fromJson(json: Map): SubscriptionPeriod { return SubscriptionPeriod( - unit = (json["unit"] as? String)?.let { SubscriptionPeriodUnit.fromJson(it) } ?: SubscriptionPeriodUnit.Day, + unit = runCatching { (json["unit"] as? String)?.let { SubscriptionPeriodUnit.fromJson(it) } }.getOrNull() ?: SubscriptionPeriodUnit.Unknown, value = (json["value"] as? Number)?.toInt() ?: 0, ) } @@ -3854,7 +4165,7 @@ public data class SubscriptionPricingTermsIOS( return SubscriptionPricingTermsIOS( billingDisplayPrice = json["billingDisplayPrice"] as? String ?: "", billingPeriod = (json["billingPeriod"] as? Map)?.let { SubscriptionPeriodValueIOS.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionPeriodValueIOS"), - billingPlanType = (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } ?: SubscriptionBillingPlanTypeIOS.Unknown, + billingPlanType = runCatching { (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown, billingPrice = (json["billingPrice"] as? Number)?.toDouble() ?: 0.0, commitmentInfo = (json["commitmentInfo"] as? Map)?.let { SubscriptionCommitmentInfoIOS.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionCommitmentInfoIOS"), subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { SubscriptionOffer.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for SubscriptionOffer") }, @@ -4262,7 +4573,7 @@ public data class WebhookEvent( receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, renewsAt = (json["renewsAt"] as? Number)?.toDouble(), source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, - subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + subscriptionState = (json["subscriptionState"] as? String)?.let { runCatching { SubscriptionState.fromJson(it) }.getOrNull() ?: SubscriptionState.Unknown }, type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, ) } @@ -4320,6 +4631,38 @@ public data class AndroidSubscriptionOfferInput( ) } +/** + * Parameters for showing a billing program information dialog (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class BillingProgramInformationDialogParamsAndroid( + /** + * Billing program. Currently only BILLING_CHOICE is supported. + */ + val billingProgram: BillingProgramAndroid = BillingProgramAndroid.BillingChoice, + /** + * External transaction token returned by the Billing Choice reporting-details flow. + */ + val externalTransactionToken: String +) { + companion object { + fun fromJson(json: Map): BillingProgramInformationDialogParamsAndroid? { + val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.BillingChoice + val externalTransactionToken = json["externalTransactionToken"] as? String + if (externalTransactionToken == null) return null + return BillingProgramInformationDialogParamsAndroid( + billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, + ) + } + } + + fun toJson(): Map = mapOf( + "billingProgram" to billingProgram.toJson(), + "externalTransactionToken" to externalTransactionToken, + ) +} + public data class DeepLinkOptions( /** * Android package name to target (required on Android) @@ -4346,32 +4689,42 @@ public data class DeepLinkOptions( } /** - * Parameters for developer billing option in purchase flow (Android) - * Used with BillingFlowParams to enable external payments flow - * Available in Google Play Billing Library 8.3.0+ + * Parameters for a developer billing option in a purchase flow (Android). + * Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). + * Only billingProgram is required; link fields are used when the selected program + * links outside the app. */ public data class DeveloperBillingOptionParamsAndroid( /** - * The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + * The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. */ val billingProgram: BillingProgramAndroid, /** - * The launch mode for the external payment link + * A pre-generated external transaction token for a Billing Choice external-link + * flow. Omit it when Google Play should provide the token in the callback. */ - val launchMode: DeveloperBillingLaunchModeAndroid, + val externalTransactionToken: String? = null, /** - * The URI where the external payment will be processed + * The launch mode for the external payment link. + * Required only when the selected billing program links outside the app. */ - val linkUri: String + val launchMode: DeveloperBillingLaunchModeAndroid? = null, + /** + * The URI where the external payment will be processed. + * Required only when the selected billing program links outside the app. + */ + val linkUri: String? = null ) { companion object { fun fromJson(json: Map): DeveloperBillingOptionParamsAndroid? { val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified - val launchMode = (json["launchMode"] as? String)?.let { DeveloperBillingLaunchModeAndroid.fromJson(it) } ?: DeveloperBillingLaunchModeAndroid.Unspecified + val externalTransactionToken = json["externalTransactionToken"] as? String + val launchMode = (json["launchMode"] as? String)?.let { DeveloperBillingLaunchModeAndroid.fromJson(it) } val linkUri = json["linkUri"] as? String - if (linkUri == null) return null return DeveloperBillingOptionParamsAndroid( billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, launchMode = launchMode, linkUri = linkUri, ) @@ -4380,7 +4733,8 @@ public data class DeveloperBillingOptionParamsAndroid( fun toJson(): Map = mapOf( "billingProgram" to billingProgram.toJson(), - "launchMode" to launchMode.toJson(), + "externalTransactionToken" to externalTransactionToken, + "launchMode" to launchMode?.toJson(), "linkUri" to linkUri, ) } @@ -4434,6 +4788,65 @@ public data class DiscountOfferInputIOS( ) } +/** + * Parameters for fetching Billing Choice display information (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +public data class GetBillingChoiceInfoParamsAndroid( + /** + * Billing program. Currently only BILLING_CHOICE is supported. + */ + val billingProgram: BillingProgramAndroid = BillingProgramAndroid.BillingChoice, + /** + * Desired Play Billing choice image layout. + */ + val playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + /** + * BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + */ + val userLocale: String? = null +) { + companion object { + fun fromJson(json: Map): GetBillingChoiceInfoParamsAndroid { + return GetBillingChoiceInfoParamsAndroid( + billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout = (json["playBillingChoiceImageLayout"] as? String)?.let { BillingChoiceImageLayoutAndroid.fromJson(it) } ?: BillingChoiceImageLayoutAndroid.RectangularFourByOne, + userLocale = json["userLocale"] as? String, + ) + } + } + + fun toJson(): Map = mapOf( + "billingProgram" to billingProgram.toJson(), + "playBillingChoiceImageLayout" to playBillingChoiceImageLayout.toJson(), + "userLocale" to userLocale, + ) +} + +/** + * Parameters for showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +public data class InAppMessageParamsAndroid( + /** + * In-app message categories to show. Defaults to transactional messages. + */ + val categories: List? = listOf(InAppMessageCategoryAndroid.Transactional) +) { + companion object { + fun fromJson(json: Map): InAppMessageParamsAndroid { + return InAppMessageParamsAndroid( + categories = (json["categories"] as? List<*>)?.mapNotNull { runCatching { (it as? String)?.let { InAppMessageCategoryAndroid.fromJson(it) } }.getOrNull() ?: InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId } ?: listOf(InAppMessageCategoryAndroid.Transactional), + ) + } + } + + fun toJson(): Map = mapOf( + "categories" to categories?.map { it.toJson() }, + ) +} + /** * Connection initialization configuration */ @@ -4445,6 +4858,16 @@ public data class InitConnectionConfig( * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. */ val alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = null, + /** + * Billing Choice renderer configured in Play Console. Available in OpenIAP + * Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + * can emit the selection event. DEVELOPER_RENDERED omits that listener so the + * app can render its own choice screen and use the reporting/dialog/link APIs. + * Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + * Defaults to GOOGLE_RENDERED. + */ + val billingChoiceScreenTypeAndroid: BillingChoiceScreenTypeAndroid? = BillingChoiceScreenTypeAndroid.GoogleRendered, /** * Enable a specific billing program for Android (7.0+) * When set, enables the specified billing program for external transactions. @@ -4452,6 +4875,8 @@ public data class InitConnectionConfig( * - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) * - EXTERNAL_OFFER: External offers for digital content (8.2.0+) * - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + * - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) */ val enableBillingProgramAndroid: BillingProgramAndroid? = null ) { @@ -4459,6 +4884,7 @@ public data class InitConnectionConfig( fun fromJson(json: Map): InitConnectionConfig { return InitConnectionConfig( alternativeBillingModeAndroid = (json["alternativeBillingModeAndroid"] as? String)?.let { AlternativeBillingModeAndroid.fromJson(it) }, + billingChoiceScreenTypeAndroid = (json["billingChoiceScreenTypeAndroid"] as? String)?.let { BillingChoiceScreenTypeAndroid.fromJson(it) } ?: BillingChoiceScreenTypeAndroid.GoogleRendered, enableBillingProgramAndroid = (json["enableBillingProgramAndroid"] as? String)?.let { BillingProgramAndroid.fromJson(it) }, ) } @@ -4466,20 +4892,28 @@ public data class InitConnectionConfig( fun toJson(): Map = mapOf( "alternativeBillingModeAndroid" to alternativeBillingModeAndroid?.toJson(), + "billingChoiceScreenTypeAndroid" to billingChoiceScreenTypeAndroid?.toJson(), "enableBillingProgramAndroid" to enableBillingProgramAndroid?.toJson(), ) } /** * Parameters for launching an external link (Android) - * Used with launchExternalLink to initiate external offer or app install flows + * Used with launchExternalLink to initiate external offer, app install, or + * developer-rendered Billing Choice flows * Available in Google Play Billing Library 8.2.0+ */ public data class LaunchExternalLinkParamsAndroid( /** - * The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) */ val billingProgram: BillingProgramAndroid, + /** + * External transaction token for a developer-rendered Billing Choice external-link + * flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + */ + val externalTransactionToken: String? = null, /** * The external link launch mode */ @@ -4496,12 +4930,14 @@ public data class LaunchExternalLinkParamsAndroid( companion object { fun fromJson(json: Map): LaunchExternalLinkParamsAndroid? { val billingProgram = (json["billingProgram"] as? String)?.let { BillingProgramAndroid.fromJson(it) } ?: BillingProgramAndroid.Unspecified + val externalTransactionToken = json["externalTransactionToken"] as? String val launchMode = (json["launchMode"] as? String)?.let { ExternalLinkLaunchModeAndroid.fromJson(it) } ?: ExternalLinkLaunchModeAndroid.Unspecified val linkType = (json["linkType"] as? String)?.let { ExternalLinkTypeAndroid.fromJson(it) } ?: ExternalLinkTypeAndroid.Unspecified val linkUri = json["linkUri"] as? String if (linkUri == null) return null return LaunchExternalLinkParamsAndroid( billingProgram = billingProgram, + externalTransactionToken = externalTransactionToken, launchMode = launchMode, linkType = linkType, linkUri = linkUri, @@ -4511,6 +4947,7 @@ public data class LaunchExternalLinkParamsAndroid( fun toJson(): Map = mapOf( "billingProgram" to billingProgram.toJson(), + "externalTransactionToken" to externalTransactionToken, "launchMode" to launchMode.toJson(), "linkType" to linkType.toJson(), "linkUri" to linkUri, @@ -4519,12 +4956,12 @@ public data class LaunchExternalLinkParamsAndroid( public data class ProductRequest( val skus: List, - val type: ProductQueryType? = null + val type: ProductQueryType? = ProductQueryType.InApp ) { companion object { fun fromJson(json: Map): ProductRequest? { val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String } - val type = (json["type"] as? String)?.let { ProductQueryType.fromJson(it) } + val type = (json["type"] as? String)?.let { ProductQueryType.fromJson(it) } ?: ProductQueryType.InApp if (skus == null) return null return ProductRequest( skus = skus, @@ -4634,9 +5071,9 @@ public data class PurchaseUpdatedListenerOptions( public data class RequestPurchaseAndroidProps( /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null, /** @@ -4854,9 +5291,9 @@ public data class RequestPurchasePropsByPlatforms( public data class RequestSubscriptionAndroidProps( /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null, /** @@ -4872,6 +5309,12 @@ public data class RequestSubscriptionAndroidProps( * Obfuscated profile ID */ val obfuscatedProfileId: String? = null, + /** + * Original external transaction ID for replacing a subscription that was + * purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + val originalExternalTransactionId: String? = null, /** * Purchase token for upgrades/downgrades */ @@ -4901,6 +5344,7 @@ public data class RequestSubscriptionAndroidProps( val isOfferPersonalized = json["isOfferPersonalized"] as? Boolean val obfuscatedAccountId = json["obfuscatedAccountId"] as? String val obfuscatedProfileId = json["obfuscatedProfileId"] as? String + val originalExternalTransactionId = json["originalExternalTransactionId"] as? String val purchaseToken = json["purchaseToken"] as? String val replacementMode = (json["replacementMode"] as? Number)?.toInt() val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String } @@ -4912,6 +5356,7 @@ public data class RequestSubscriptionAndroidProps( isOfferPersonalized = isOfferPersonalized, obfuscatedAccountId = obfuscatedAccountId, obfuscatedProfileId = obfuscatedProfileId, + originalExternalTransactionId = originalExternalTransactionId, purchaseToken = purchaseToken, replacementMode = replacementMode, skus = skus, @@ -4926,6 +5371,7 @@ public data class RequestSubscriptionAndroidProps( "isOfferPersonalized" to isOfferPersonalized, "obfuscatedAccountId" to obfuscatedAccountId, "obfuscatedProfileId" to obfuscatedProfileId, + "originalExternalTransactionId" to originalExternalTransactionId, "purchaseToken" to purchaseToken, "replacementMode" to replacementMode, "skus" to skus, @@ -4982,7 +5428,7 @@ public data class RequestSubscriptionIosProps( val advancedCommerceData = json["advancedCommerceData"] as? String val andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as? Boolean val appAccountToken = json["appAccountToken"] as? String - val billingPlanType = (json["billingPlanType"] as? String)?.let { SubscriptionBillingPlanTypeIOS.fromJson(it) } + val billingPlanType = (json["billingPlanType"] as? String)?.let { runCatching { SubscriptionBillingPlanTypeIOS.fromJson(it) }.getOrNull() ?: SubscriptionBillingPlanTypeIOS.Unknown } val compactJWS = json["compactJWS"] as? String val promotionalOfferJWS = (json["promotionalOfferJWS"] as? Map)?.let { PromotionalOfferJWSInputIOS.fromJson(it) } val quantity = (json["quantity"] as? Number)?.toInt() @@ -5204,7 +5650,7 @@ public data class SubscriptionProductReplacementParamsAndroid( companion object { fun fromJson(json: Map): SubscriptionProductReplacementParamsAndroid? { val oldProductId = json["oldProductId"] as? String - val replacementMode = (json["replacementMode"] as? String)?.let { SubscriptionReplacementModeAndroid.fromJson(it) } ?: SubscriptionReplacementModeAndroid.UnknownReplacementMode + val replacementMode = runCatching { (json["replacementMode"] as? String)?.let { SubscriptionReplacementModeAndroid.fromJson(it) } }.getOrNull() ?: SubscriptionReplacementModeAndroid.UnknownReplacementMode if (oldProductId == null) return null return SubscriptionProductReplacementParamsAndroid( oldProductId = oldProductId, @@ -5531,7 +5977,6 @@ public interface MutationResolver { suspend fun beginRefundRequestIOS(sku: String): String? /** * Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -5551,7 +5996,6 @@ public interface MutationResolver { * Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. * Must be called AFTER successful payment in your payment system. * Token must be reported to Google Play backend within 24 hours. - * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -5560,12 +6004,15 @@ public interface MutationResolver { /** * Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). * Replaces the deprecated createExternalOfferReportingDetailsAsync API. - * * Returns external transaction token needed for reporting external transactions. + * developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + * native Android defaults it to IN_APP. + * The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ - suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid + suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid? = null): BillingProgramReportingDetailsAndroid /** * Open the platform's subscription management UI. * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions @@ -5589,7 +6036,6 @@ public interface MutationResolver { /** * Check whether a billing program (e.g., External Payments) is available for the current user. * Replaces the deprecated isExternalOfferAvailableAsync API. - * * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. @@ -5597,9 +6043,11 @@ public interface MutationResolver { */ suspend fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid /** - * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + * including developer-rendered Billing Choice external-link flows. + * Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). * Replaces the deprecated showExternalOfferInformationDialog API. - * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -5644,12 +6092,19 @@ public interface MutationResolver { /** * Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. * Must be called BEFORE processing payment in your payment system. - * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ suspend fun showAlternativeBillingDialogAndroid(): Boolean + /** + * Show Google's mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + */ + suspend fun showBillingProgramInformationDialogAndroid(params: BillingProgramInformationDialogParamsAndroid): BillingResultAndroid /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. @@ -5657,6 +6112,15 @@ public interface MutationResolver { * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS + /** + * Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + * Returns a response code and, when the subscription status changes, the related purchase token. + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + */ + suspend fun showInAppMessagesAndroid(params: InAppMessageParamsAndroid? = null): InAppMessageResultAndroid /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios @@ -5733,6 +6197,13 @@ public interface QueryResolver { * See: https://openiap.dev/docs/apis/get-available-purchases */ suspend fun getAvailablePurchases(options: PurchaseOptions? = null): List + /** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client is not ready. + * See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + */ + suspend fun getBillingChoiceInfoAndroid(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. @@ -5814,11 +6285,11 @@ public interface QueryResolver { */ public interface SubscriptionResolver { /** - * Fires when a user selects developer billing in the External Payments flow (Android only) - * Triggered when the user chooses to pay via the developer's external payment option - * instead of Google Play Billing in the side-by-side choice dialog. - * Contains the externalTransactionToken needed to report the transaction. - * Available in Google Play Billing Library 8.3.0+ + * Fires when a user selects developer billing in an External Payments or + * Billing Choice flow (Android only). The payload can contain an external + * transaction token, link URI, original transaction ID, and selected products. + * Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ suspend fun developerProvidedBillingAndroid(): DeveloperProvidedBillingDetailsAndroid /** @@ -5867,7 +6338,7 @@ public typealias MutationCheckAlternativeBillingAvailabilityAndroidHandler = sus public typealias MutationClearTransactionIOSHandler = suspend () -> Boolean public typealias MutationConsumePurchaseAndroidHandler = suspend (purchaseToken: String) -> Boolean public typealias MutationCreateAlternativeBillingTokenAndroidHandler = suspend () -> String? -public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = suspend (program: BillingProgramAndroid) -> BillingProgramReportingDetailsAndroid +public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = suspend (program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid?) -> BillingProgramReportingDetailsAndroid public typealias MutationDeepLinkToSubscriptionsHandler = suspend (options: DeepLinkOptions?) -> Unit public typealias MutationEndConnectionHandler = suspend () -> Boolean public typealias MutationFinishTransactionHandler = suspend (purchase: PurchaseInput, isConsumable: Boolean?) -> Unit @@ -5881,7 +6352,9 @@ public typealias MutationRequestPurchaseHandler = suspend (params: RequestPurcha public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = suspend () -> Boolean public typealias MutationRestorePurchasesHandler = suspend () -> Unit public typealias MutationShowAlternativeBillingDialogAndroidHandler = suspend () -> Boolean +public typealias MutationShowBillingProgramInformationDialogAndroidHandler = suspend (params: BillingProgramInformationDialogParamsAndroid) -> BillingResultAndroid public typealias MutationShowExternalPurchaseCustomLinkNoticeIOSHandler = suspend (noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) -> ExternalPurchaseCustomLinkNoticeResultIOS +public typealias MutationShowInAppMessagesAndroidHandler = suspend (params: InAppMessageParamsAndroid?) -> InAppMessageResultAndroid public typealias MutationShowManageSubscriptionsIOSHandler = suspend () -> List public typealias MutationSyncIOSHandler = suspend () -> Boolean public typealias MutationValidateReceiptHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResult @@ -5909,7 +6382,9 @@ public data class MutationHandlers( val requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = null, val restorePurchases: MutationRestorePurchasesHandler? = null, val showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = null, + val showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidHandler? = null, val showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? = null, + val showInAppMessagesAndroid: MutationShowInAppMessagesAndroidHandler? = null, val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null, val syncIOS: MutationSyncIOSHandler? = null, val validateReceipt: MutationValidateReceiptHandler? = null, @@ -5926,6 +6401,7 @@ public typealias QueryGetActiveSubscriptionsHandler = suspend (subscriptionIds: public typealias QueryGetAllTransactionsIOSHandler = suspend () -> List public typealias QueryGetAppTransactionIOSHandler = suspend () -> AppTransaction? public typealias QueryGetAvailablePurchasesHandler = suspend (options: PurchaseOptions?) -> List +public typealias QueryGetBillingChoiceInfoAndroidHandler = suspend (params: GetBillingChoiceInfoParamsAndroid) -> BillingChoiceInfoAndroid public typealias QueryGetExternalPurchaseCustomLinkTokenIOSHandler = suspend (tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) -> ExternalPurchaseCustomLinkTokenResultIOS public typealias QueryGetPendingTransactionsIOSHandler = suspend () -> List public typealias QueryGetPromotedProductIOSHandler = suspend () -> ProductIOS? @@ -5949,6 +6425,7 @@ public data class QueryHandlers( val getAllTransactionsIOS: QueryGetAllTransactionsIOSHandler? = null, val getAppTransactionIOS: QueryGetAppTransactionIOSHandler? = null, val getAvailablePurchases: QueryGetAvailablePurchasesHandler? = null, + val getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidHandler? = null, val getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIOSHandler? = null, val getPendingTransactionsIOS: QueryGetPendingTransactionsIOSHandler? = null, val getPromotedProductIOS: QueryGetPromotedProductIOSHandler? = null, diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 656556304..500c35895 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -24,8 +24,32 @@ public enum AlternativeBillingModeAndroid: String, Codable, CaseIterable { case alternativeOnly = "alternative-only" } -/// Billing program types for external content links, external offers, and external payments (Android) -/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +/// Play Billing choice image layout (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public enum BillingChoiceImageLayoutAndroid: String, Codable, CaseIterable { + /// Rectangular image with a 4:1 aspect ratio. + case rectangularFourByOne = "rectangular-four-by-one" + /// Rectangular image with a 3:1 aspect ratio. + case rectangularThreeByOne = "rectangular-three-by-one" + /// Rectangular image with a 2:2 aspect ratio. + case rectangularTwoByTwo = "rectangular-two-by-two" +} + +/// Choice screen renderer for Billing Choice availability (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public enum BillingChoiceScreenTypeAndroid: String, Codable, CaseIterable { + /// Unspecified choice screen type. + case unspecified = "unspecified" + /// Choice screen is rendered by the developer app. + case developerRendered = "developer-rendered" + /// Choice screen is rendered by Google Play. + case googleRendered = "google-rendered" +} + +/// Billing program types for Google Play Billing Programs (Android) +/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, +/// BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (requires Play Billing 9.1.0+). public enum BillingProgramAndroid: String, Codable, CaseIterable { /// Unspecified billing program. Do not use. case unspecified = "unspecified" @@ -46,6 +70,10 @@ public enum BillingProgramAndroid: String, Codable, CaseIterable { /// Users can choose to complete the purchase on the developer's website. /// Available in Google Play Billing Library 8.3.0+ case externalPayments = "external-payments" + /// Billing Choice program. + /// Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + case billingChoice = "billing-choice" } /// Launch mode for developer billing option (Android) @@ -62,6 +90,17 @@ public enum DeveloperBillingLaunchModeAndroid: String, Codable, CaseIterable { case callerWillLaunchLink = "caller-will-launch-link" } +/// Developer-provided billing destination type for Billing Program reporting details (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public enum DeveloperBillingTypeAndroid: String, Codable, CaseIterable { + /// Unspecified developer billing type. Do not use. + case developerBillingTypeUnspecified = "developer-billing-type-unspecified" + /// Developer-provided billing via native in-app experience. + case inApp = "in-app" + /// Developer-provided billing via external link or embedded web browsing. + case externalLink = "external-link" +} + /// Discount offer type enumeration. /// Categorizes the type of discount or promotional offer. public enum DiscountOfferType: String, Codable, CaseIterable { @@ -261,8 +300,9 @@ public enum IapEvent: String, Codable, CaseIterable { case purchaseError = "purchase-error" case promotedProductIos = "promoted-product-ios" case userChoiceBillingAndroid = "user-choice-billing-android" - /// Fired when user selects developer-provided billing option in external payments flow. - /// Available on Android with Google Play Billing Library 8.3.0+ + /// Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + /// developer billing selections on Android. Billing Choice is available in + /// OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). case developerProvidedBillingAndroid = "developer-provided-billing-android" /// Fired when an active subscription enters a billing-issue state that requires user attention. /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and @@ -306,6 +346,26 @@ public enum IapStore: String, Codable, CaseIterable { case amazon = "amazon" } +/// High-level in-app message category (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public enum InAppMessageCategoryAndroid: String, Codable, CaseIterable { + /// Unknown in-app message category. + case unknownInAppMessageCategoryId = "unknown-in-app-message-category-id" + /// Transactional billing messages, such as payment issues or pending price-change confirmations. + case transactional = "transactional" +} + +/// Response code from Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public enum InAppMessageResponseCodeAndroid: String, Codable, CaseIterable { + /// Flow finished and no developer action is needed. + case noActionNeeded = "no-action-needed" + /// Subscription status changed and the purchase token should be checked. + case subscriptionStatusUpdated = "subscription-status-updated" +} + /// Payment mode for subscription offers. /// Determines how the user pays during the offer period. public enum PaymentMode: String, Codable, CaseIterable { @@ -573,6 +633,7 @@ public protocol PurchaseCommon: Codable { var quantity: Int { get } /// Store where purchase was made var store: IapStore { get } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. var transactionDate: Double { get } } @@ -597,6 +658,7 @@ public struct ActiveSubscription: Codable { /// Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, /// pending upgrades/downgrades, and auto-renewal preferences. public var renewalInfoIOS: RenewalInfoIOS? = nil + /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double public var transactionId: String /// @deprecated iOS only - use daysUntilExpirationIOS instead. @@ -667,13 +729,29 @@ public struct AppTransaction: Codable { public var signedDate: Double } +/// Display information for developer-rendered Billing Choice screens (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public struct BillingChoiceInfoAndroid: Codable { + /// URL for the Play Billing choice image matching the requested layout. + public var playBillingChoiceImageUrl: String + /// Play Loyalty information for the user. + public var playBillingLoyaltyInfo: String? = nil +} + /// Result of checking billing program availability (Android) /// Available in Google Play Billing Library 8.2.0+ public struct BillingProgramAvailabilityResultAndroid: Codable { /// The billing program that was checked public var billingProgram: BillingProgramAndroid + /// Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + public var choiceScreenType: BillingChoiceScreenTypeAndroid? = nil /// Whether the billing program is available for the user public var isAvailable: Bool + /// Whether external-link payment is available for Billing Choice. + /// Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + public var isExternalLinkAvailable: Bool? = nil } /// Reporting details for transactions made outside of Google Play Billing (Android) @@ -704,9 +782,26 @@ public struct BillingResultAndroid: Codable { /// Available in Google Play Billing Library 8.3.0+ public struct DeveloperProvidedBillingDetailsAndroid: Codable { /// External transaction token used to report transactions made through developer billing. - /// This token must be used when reporting the external transaction to Google Play. - /// Must be reported within 24 hours of the transaction. - public var externalTransactionToken: String + /// Nullable for flows such as external payments where no token is returned. + public var externalTransactionToken: String? = nil + /// URI to launch for an external-link Billing Choice flow, when provided by + /// Google Play. + public var linkUri: String? = nil + /// Original external transaction ID when replacing a subscription that was + /// purchased through developer billing. + public var originalExternalTransactionId: String? = nil + /// Products selected for the developer billing flow. + public var products: [DeveloperProvidedBillingProductAndroid] +} + +/// Product selected for developer-provided billing (Android 9.0+). +public struct DeveloperProvidedBillingProductAndroid: Codable { + /// Product identifier. + public var id: String + /// Subscription offer token, when applicable. + public var offerToken: String? = nil + /// Google Play product type (in-app or subscription). + public var type: ProductType } /// Discount amount details for one-time purchase offers (Android) @@ -879,6 +974,16 @@ public enum FetchProductsResult { case subscriptions([ProductSubscription]?) } +/// Result from showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public struct InAppMessageResultAndroid: Codable { + /// Purchase token returned when a subscription status changed. + public var purchaseToken: String? = nil + /// Response code for the in-app messaging flow. + public var responseCode: InAppMessageResponseCodeAndroid +} + /// Installment plan details for subscription offers (Android) /// Contains information about the installment plan commitment. /// Available in Google Play Billing Library 7.0+ @@ -1154,6 +1259,7 @@ public struct PurchaseAndroid: Codable, PurchaseCommon { public var signatureAndroid: String? = nil /// Store where purchase was made public var store: IapStore + /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double public var transactionId: String? = nil } @@ -1209,6 +1315,7 @@ public struct PurchaseIOS: Codable, PurchaseCommon { public var store: IapStore public var storefrontCountryCodeIOS: String? = nil public var subscriptionGroupIdIOS: String? = nil + /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double public var transactionId: String public var transactionReasonIOS: String? = nil @@ -1549,6 +1656,23 @@ public struct AndroidSubscriptionOfferInput: Codable { } } +/// Parameters for showing a billing program information dialog (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public struct BillingProgramInformationDialogParamsAndroid: Codable { + /// Billing program. Currently only BILLING_CHOICE is supported. + public var billingProgram: BillingProgramAndroid + /// External transaction token returned by the Billing Choice reporting-details flow. + public var externalTransactionToken: String + + public init( + billingProgram: BillingProgramAndroid = .billingChoice, + externalTransactionToken: String + ) { + self.billingProgram = billingProgram + self.externalTransactionToken = externalTransactionToken + } +} + public struct DeepLinkOptions: Codable { /// Android package name to target (required on Android) public var packageNameAndroid: String? @@ -1564,23 +1688,32 @@ public struct DeepLinkOptions: Codable { } } -/// Parameters for developer billing option in purchase flow (Android) -/// Used with BillingFlowParams to enable external payments flow -/// Available in Google Play Billing Library 8.3.0+ +/// Parameters for a developer billing option in a purchase flow (Android). +/// Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice +/// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). +/// Only billingProgram is required; link fields are used when the selected program +/// links outside the app. public struct DeveloperBillingOptionParamsAndroid: Codable { - /// The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + /// The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. public var billingProgram: BillingProgramAndroid - /// The launch mode for the external payment link - public var launchMode: DeveloperBillingLaunchModeAndroid - /// The URI where the external payment will be processed - public var linkUri: String + /// A pre-generated external transaction token for a Billing Choice external-link + /// flow. Omit it when Google Play should provide the token in the callback. + public var externalTransactionToken: String? + /// The launch mode for the external payment link. + /// Required only when the selected billing program links outside the app. + public var launchMode: DeveloperBillingLaunchModeAndroid? + /// The URI where the external payment will be processed. + /// Required only when the selected billing program links outside the app. + public var linkUri: String? public init( billingProgram: BillingProgramAndroid, - launchMode: DeveloperBillingLaunchModeAndroid, - linkUri: String + externalTransactionToken: String? = nil, + launchMode: DeveloperBillingLaunchModeAndroid? = nil, + linkUri: String? = nil ) { self.billingProgram = billingProgram + self.externalTransactionToken = externalTransactionToken self.launchMode = launchMode self.linkUri = linkUri } @@ -1637,6 +1770,41 @@ public struct DiscountOfferInputIOS: Codable { } } +/// Parameters for fetching Billing Choice display information (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +public struct GetBillingChoiceInfoParamsAndroid: Codable { + /// Billing program. Currently only BILLING_CHOICE is supported. + public var billingProgram: BillingProgramAndroid + /// Desired Play Billing choice image layout. + public var playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid + /// BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + public var userLocale: String? + + public init( + billingProgram: BillingProgramAndroid = .billingChoice, + playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid = .rectangularFourByOne, + userLocale: String? = nil + ) { + self.billingProgram = billingProgram + self.playBillingChoiceImageLayout = playBillingChoiceImageLayout + self.userLocale = userLocale + } +} + +/// Parameters for showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +public struct InAppMessageParamsAndroid: Codable { + /// In-app message categories to show. Defaults to transactional messages. + public var categories: [InAppMessageCategoryAndroid]? + + public init( + categories: [InAppMessageCategoryAndroid]? = [.transactional] + ) { + self.categories = categories + } +} + /// Connection initialization configuration public struct InitConnectionConfig: Codable { /// Alternative billing mode for Android @@ -1644,29 +1812,46 @@ public struct InitConnectionConfig: Codable { /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. public var alternativeBillingModeAndroid: AlternativeBillingModeAndroid? + /// Billing Choice renderer configured in Play Console. Available in OpenIAP + /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + /// can emit the selection event. DEVELOPER_RENDERED omits that listener so the + /// app can render its own choice screen and use the reporting/dialog/link APIs. + /// Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + /// Defaults to GOOGLE_RENDERED. + public var billingChoiceScreenTypeAndroid: BillingChoiceScreenTypeAndroid? /// Enable a specific billing program for Android (7.0+) /// When set, enables the specified billing program for external transactions. /// - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) /// - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) /// - EXTERNAL_OFFER: External offers for digital content (8.2.0+) /// - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + /// - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + /// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) public var enableBillingProgramAndroid: BillingProgramAndroid? public init( alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = nil, + billingChoiceScreenTypeAndroid: BillingChoiceScreenTypeAndroid? = .googleRendered, enableBillingProgramAndroid: BillingProgramAndroid? = nil ) { self.alternativeBillingModeAndroid = alternativeBillingModeAndroid + self.billingChoiceScreenTypeAndroid = billingChoiceScreenTypeAndroid self.enableBillingProgramAndroid = enableBillingProgramAndroid } } /// Parameters for launching an external link (Android) -/// Used with launchExternalLink to initiate external offer or app install flows +/// Used with launchExternalLink to initiate external offer, app install, or +/// developer-rendered Billing Choice flows /// Available in Google Play Billing Library 8.2.0+ public struct LaunchExternalLinkParamsAndroid: Codable { - /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + /// The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) public var billingProgram: BillingProgramAndroid + /// External transaction token for a developer-rendered Billing Choice external-link + /// flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + public var externalTransactionToken: String? /// The external link launch mode public var launchMode: ExternalLinkLaunchModeAndroid /// The type of the external link @@ -1676,11 +1861,13 @@ public struct LaunchExternalLinkParamsAndroid: Codable { public init( billingProgram: BillingProgramAndroid, + externalTransactionToken: String? = nil, launchMode: ExternalLinkLaunchModeAndroid, linkType: ExternalLinkTypeAndroid, linkUri: String ) { self.billingProgram = billingProgram + self.externalTransactionToken = externalTransactionToken self.launchMode = launchMode self.linkType = linkType self.linkUri = linkUri @@ -1693,7 +1880,7 @@ public struct ProductRequest: Codable { public init( skus: [String], - type: ProductQueryType? = nil + type: ProductQueryType? = .inApp ) { self.skus = skus self.type = type @@ -1759,9 +1946,9 @@ public struct PurchaseUpdatedListenerOptions: Codable { } public struct RequestPurchaseAndroidProps: Codable { - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). public var developerBillingOption: DeveloperBillingOptionParamsAndroid? /// Personalized offer flag. /// When true, indicates the price was customized for this user. @@ -1930,9 +2117,9 @@ public struct RequestPurchasePropsByPlatforms: Codable { } public struct RequestSubscriptionAndroidProps: Codable { - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). public var developerBillingOption: DeveloperBillingOptionParamsAndroid? /// Personalized offer flag. /// When true, indicates the price was customized for this user. @@ -1941,6 +2128,10 @@ public struct RequestSubscriptionAndroidProps: Codable { public var obfuscatedAccountId: String? /// Obfuscated profile ID public var obfuscatedProfileId: String? + /// Original external transaction ID for replacing a subscription that was + /// purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). + public var originalExternalTransactionId: String? /// Purchase token for upgrades/downgrades public var purchaseToken: String? /// Replacement mode for subscription changes @@ -1959,6 +2150,7 @@ public struct RequestSubscriptionAndroidProps: Codable { isOfferPersonalized: Bool? = nil, obfuscatedAccountId: String? = nil, obfuscatedProfileId: String? = nil, + originalExternalTransactionId: String? = nil, purchaseToken: String? = nil, replacementMode: Int? = nil, skus: [String], @@ -1969,6 +2161,7 @@ public struct RequestSubscriptionAndroidProps: Codable { self.isOfferPersonalized = isOfferPersonalized self.obfuscatedAccountId = obfuscatedAccountId self.obfuscatedProfileId = obfuscatedProfileId + self.originalExternalTransactionId = originalExternalTransactionId self.purchaseToken = purchaseToken self.replacementMode = replacementMode self.skus = skus @@ -2570,6 +2763,7 @@ public enum Purchase: Codable, PurchaseCommon { } } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. public var transactionDate: Double { switch self { case let .purchaseAndroid(value): @@ -2597,7 +2791,6 @@ public protocol MutationResolver { /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios func beginRefundRequestIOS(_ sku: String) async throws -> String? /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -2611,18 +2804,20 @@ public protocol MutationResolver { /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. /// Token must be reported to Google Play backend within 24 hours. - /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android func createAlternativeBillingTokenAndroid() async throws -> String? /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. - /// /// Returns external transaction token needed for reporting external transactions. + /// developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + /// native Android defaults it to IN_APP. + /// The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android - func createBillingProgramReportingDetailsAndroid(_ program: BillingProgramAndroid) async throws -> BillingProgramReportingDetailsAndroid + func createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid?) async throws -> BillingProgramReportingDetailsAndroid /// Open the platform's subscription management UI. /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void @@ -2637,15 +2832,16 @@ public protocol MutationResolver { func initConnection(_ config: InitConnectionConfig?) async throws -> Bool /// Check whether a billing program (e.g., External Payments) is available for the current user. /// Replaces the deprecated isExternalOfferAvailableAsync API. - /// /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android func isBillingProgramAvailableAndroid(_ program: BillingProgramAndroid) async throws -> BillingProgramAvailabilityResultAndroid - /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + /// including developer-rendered Billing Choice external-link flows. + /// Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. - /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -2676,16 +2872,28 @@ public protocol MutationResolver { func restorePurchases() async throws -> Void /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. - /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android func showAlternativeBillingDialogAndroid() async throws -> Bool + /// Show Google's mandatory information dialog before a developer-rendered, + /// in-app Billing Choice screen. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + func showBillingProgramInformationDialogAndroid(_ params: BillingProgramInformationDialogParamsAndroid) async throws -> BillingResultAndroid /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios func showExternalPurchaseCustomLinkNoticeIOS(_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS + /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + /// (upstream API available since Play Billing 4.1.0). + /// Returns a response code and, when the subscription status changes, the related purchase token. + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + func showInAppMessagesAndroid(_ params: InAppMessageParamsAndroid?) async throws -> InAppMessageResultAndroid /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] @@ -2736,6 +2944,11 @@ public protocol QueryResolver { /// List active purchases for the current user. /// See: https://openiap.dev/docs/apis/get-available-purchases func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] + /// Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client is not ready. + /// See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + func getBillingChoiceInfoAndroid(_ params: GetBillingChoiceInfoParamsAndroid) async throws -> BillingChoiceInfoAndroid /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) @@ -2786,11 +2999,11 @@ public protocol QueryResolver { /// GraphQL root subscription operations. public protocol SubscriptionResolver { - /// Fires when a user selects developer billing in the External Payments flow (Android only) - /// Triggered when the user chooses to pay via the developer's external payment option - /// instead of Google Play Billing in the side-by-side choice dialog. - /// Contains the externalTransactionToken needed to report the transaction. - /// Available in Google Play Billing Library 8.3.0+ + /// Fires when a user selects developer billing in an External Payments or + /// Billing Choice flow (Android only). The payload can contain an external + /// transaction token, link URI, original transaction ID, and selected products. + /// Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). func developerProvidedBillingAndroid() async throws -> DeveloperProvidedBillingDetailsAndroid /// Fires when the App Store surfaces a promoted product (iOS only) func promotedProductIOS() async throws -> String @@ -2828,7 +3041,7 @@ public typealias MutationCheckAlternativeBillingAvailabilityAndroidHandler = () public typealias MutationClearTransactionIOSHandler = () async throws -> Bool public typealias MutationConsumePurchaseAndroidHandler = (_ purchaseToken: String) async throws -> Bool public typealias MutationCreateAlternativeBillingTokenAndroidHandler = () async throws -> String? -public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = (_ program: BillingProgramAndroid) async throws -> BillingProgramReportingDetailsAndroid +public typealias MutationCreateBillingProgramReportingDetailsAndroidHandler = (_ program: BillingProgramAndroid, _ developerBillingType: DeveloperBillingTypeAndroid?) async throws -> BillingProgramReportingDetailsAndroid public typealias MutationDeepLinkToSubscriptionsHandler = (_ options: DeepLinkOptions?) async throws -> Void public typealias MutationEndConnectionHandler = () async throws -> Bool public typealias MutationFinishTransactionHandler = (_ purchase: PurchaseInput, _ isConsumable: Bool?) async throws -> Void @@ -2842,7 +3055,9 @@ public typealias MutationRequestPurchaseHandler = (_ params: RequestPurchaseProp public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = () async throws -> Bool public typealias MutationRestorePurchasesHandler = () async throws -> Void public typealias MutationShowAlternativeBillingDialogAndroidHandler = () async throws -> Bool +public typealias MutationShowBillingProgramInformationDialogAndroidHandler = (_ params: BillingProgramInformationDialogParamsAndroid) async throws -> BillingResultAndroid public typealias MutationShowExternalPurchaseCustomLinkNoticeIOSHandler = (_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS +public typealias MutationShowInAppMessagesAndroidHandler = (_ params: InAppMessageParamsAndroid?) async throws -> InAppMessageResultAndroid public typealias MutationShowManageSubscriptionsIOSHandler = () async throws -> [PurchaseIOS] public typealias MutationSyncIOSHandler = () async throws -> Bool public typealias MutationValidateReceiptHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult @@ -2870,7 +3085,9 @@ public struct MutationHandlers { public var requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? public var restorePurchases: MutationRestorePurchasesHandler? public var showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? + public var showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidHandler? public var showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? + public var showInAppMessagesAndroid: MutationShowInAppMessagesAndroidHandler? public var showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? public var syncIOS: MutationSyncIOSHandler? public var validateReceipt: MutationValidateReceiptHandler? @@ -2898,7 +3115,9 @@ public struct MutationHandlers { requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = nil, restorePurchases: MutationRestorePurchasesHandler? = nil, showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = nil, + showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidHandler? = nil, showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? = nil, + showInAppMessagesAndroid: MutationShowInAppMessagesAndroidHandler? = nil, showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = nil, syncIOS: MutationSyncIOSHandler? = nil, validateReceipt: MutationValidateReceiptHandler? = nil, @@ -2925,7 +3144,9 @@ public struct MutationHandlers { self.requestPurchaseOnPromotedProductIOS = requestPurchaseOnPromotedProductIOS self.restorePurchases = restorePurchases self.showAlternativeBillingDialogAndroid = showAlternativeBillingDialogAndroid + self.showBillingProgramInformationDialogAndroid = showBillingProgramInformationDialogAndroid self.showExternalPurchaseCustomLinkNoticeIOS = showExternalPurchaseCustomLinkNoticeIOS + self.showInAppMessagesAndroid = showInAppMessagesAndroid self.showManageSubscriptionsIOS = showManageSubscriptionsIOS self.syncIOS = syncIOS self.validateReceipt = validateReceipt @@ -2943,6 +3164,7 @@ public typealias QueryGetActiveSubscriptionsHandler = (_ subscriptionIds: [Strin public typealias QueryGetAllTransactionsIOSHandler = () async throws -> [PurchaseIOS] public typealias QueryGetAppTransactionIOSHandler = () async throws -> AppTransaction? public typealias QueryGetAvailablePurchasesHandler = (_ options: PurchaseOptions?) async throws -> [Purchase] +public typealias QueryGetBillingChoiceInfoAndroidHandler = (_ params: GetBillingChoiceInfoParamsAndroid) async throws -> BillingChoiceInfoAndroid public typealias QueryGetExternalPurchaseCustomLinkTokenIOSHandler = (_ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) async throws -> ExternalPurchaseCustomLinkTokenResultIOS public typealias QueryGetPendingTransactionsIOSHandler = () async throws -> [PurchaseIOS] public typealias QueryGetPromotedProductIOSHandler = () async throws -> ProductIOS? @@ -2966,6 +3188,7 @@ public struct QueryHandlers { public var getAllTransactionsIOS: QueryGetAllTransactionsIOSHandler? public var getAppTransactionIOS: QueryGetAppTransactionIOSHandler? public var getAvailablePurchases: QueryGetAvailablePurchasesHandler? + public var getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidHandler? public var getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIOSHandler? public var getPendingTransactionsIOS: QueryGetPendingTransactionsIOSHandler? public var getPromotedProductIOS: QueryGetPromotedProductIOSHandler? @@ -2989,6 +3212,7 @@ public struct QueryHandlers { getAllTransactionsIOS: QueryGetAllTransactionsIOSHandler? = nil, getAppTransactionIOS: QueryGetAppTransactionIOSHandler? = nil, getAvailablePurchases: QueryGetAvailablePurchasesHandler? = nil, + getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidHandler? = nil, getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIOSHandler? = nil, getPendingTransactionsIOS: QueryGetPendingTransactionsIOSHandler? = nil, getPromotedProductIOS: QueryGetPromotedProductIOSHandler? = nil, @@ -3011,6 +3235,7 @@ public struct QueryHandlers { self.getAllTransactionsIOS = getAllTransactionsIOS self.getAppTransactionIOS = getAppTransactionIOS self.getAvailablePurchases = getAvailablePurchases + self.getBillingChoiceInfoAndroid = getBillingChoiceInfoAndroid self.getExternalPurchaseCustomLinkTokenIOS = getExternalPurchaseCustomLinkTokenIOS self.getPendingTransactionsIOS = getPendingTransactionsIOS self.getPromotedProductIOS = getPromotedProductIOS diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 1b9bf3fb5..4dce97a37 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -44,8 +44,68 @@ enum AlternativeBillingModeAndroid { String toJson() => value; } -/// Billing program types for external content links, external offers, and external payments (Android) -/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +/// Play Billing choice image layout (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum BillingChoiceImageLayoutAndroid { + /// Rectangular image with a 4:1 aspect ratio. + RectangularFourByOne('rectangular-four-by-one'), + /// Rectangular image with a 3:1 aspect ratio. + RectangularThreeByOne('rectangular-three-by-one'), + /// Rectangular image with a 2:2 aspect ratio. + RectangularTwoByTwo('rectangular-two-by-two'); + + const BillingChoiceImageLayoutAndroid(this.value); + final String value; + + factory BillingChoiceImageLayoutAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'rectangular-four-by-one': + return BillingChoiceImageLayoutAndroid.RectangularFourByOne; + case 'rectangular-three-by-one': + return BillingChoiceImageLayoutAndroid.RectangularThreeByOne; + case 'rectangular-two-by-two': + return BillingChoiceImageLayoutAndroid.RectangularTwoByTwo; + } + throw ArgumentError('Unknown BillingChoiceImageLayoutAndroid value: $value'); + } + + String toJson() => value; +} + +/// Choice screen renderer for Billing Choice availability (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum BillingChoiceScreenTypeAndroid { + /// Unspecified choice screen type. + Unspecified('unspecified'), + /// Choice screen is rendered by the developer app. + DeveloperRendered('developer-rendered'), + /// Choice screen is rendered by Google Play. + GoogleRendered('google-rendered'); + + const BillingChoiceScreenTypeAndroid(this.value); + final String value; + + factory BillingChoiceScreenTypeAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'unspecified': + return BillingChoiceScreenTypeAndroid.Unspecified; + case 'developer-rendered': + return BillingChoiceScreenTypeAndroid.DeveloperRendered; + case 'google-rendered': + return BillingChoiceScreenTypeAndroid.GoogleRendered; + } + throw ArgumentError('Unknown BillingChoiceScreenTypeAndroid value: $value'); + } + + String toJson() => value; +} + +/// Billing program types for Google Play Billing Programs (Android) +/// Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, +/// BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (requires Play Billing 9.1.0+). enum BillingProgramAndroid { /// Unspecified billing program. Do not use. Unspecified('unspecified'), @@ -65,7 +125,11 @@ enum BillingProgramAndroid { /// Allows presenting a side-by-side choice between Google Play Billing and developer's external payment option. /// Users can choose to complete the purchase on the developer's website. /// Available in Google Play Billing Library 8.3.0+ - ExternalPayments('external-payments'); + ExternalPayments('external-payments'), + /// Billing Choice program. + /// Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + BillingChoice('billing-choice'); const BillingProgramAndroid(this.value); final String value; @@ -83,6 +147,8 @@ enum BillingProgramAndroid { return BillingProgramAndroid.ExternalOffer; case 'external-payments': return BillingProgramAndroid.ExternalPayments; + case 'billing-choice': + return BillingProgramAndroid.BillingChoice; } throw ArgumentError('Unknown BillingProgramAndroid value: $value'); } @@ -122,6 +188,35 @@ enum DeveloperBillingLaunchModeAndroid { String toJson() => value; } +/// Developer-provided billing destination type for Billing Program reporting details (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum DeveloperBillingTypeAndroid { + /// Unspecified developer billing type. Do not use. + DeveloperBillingTypeUnspecified('developer-billing-type-unspecified'), + /// Developer-provided billing via native in-app experience. + InApp('in-app'), + /// Developer-provided billing via external link or embedded web browsing. + ExternalLink('external-link'); + + const DeveloperBillingTypeAndroid(this.value); + final String value; + + factory DeveloperBillingTypeAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'developer-billing-type-unspecified': + return DeveloperBillingTypeAndroid.DeveloperBillingTypeUnspecified; + case 'in-app': + return DeveloperBillingTypeAndroid.InApp; + case 'external-link': + return DeveloperBillingTypeAndroid.ExternalLink; + } + throw ArgumentError('Unknown DeveloperBillingTypeAndroid value: $value'); + } + + String toJson() => value; +} + /// Discount offer type enumeration. /// Categorizes the type of discount or promotional offer. enum DiscountOfferType { @@ -423,8 +518,9 @@ enum IapEvent { PurchaseError('purchase-error'), PromotedProductIOS('promoted-product-ios'), UserChoiceBillingAndroid('user-choice-billing-android'), - /// Fired when user selects developer-provided billing option in external payments flow. - /// Available on Android with Google Play Billing Library 8.3.0+ + /// Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + /// developer billing selections on Android. Billing Choice is available in + /// OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). DeveloperProvidedBillingAndroid('developer-provided-billing-android'), /// Fired when an active subscription enters a billing-issue state that requires user attention. /// Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and @@ -560,6 +656,58 @@ enum IapStore { String toJson() => value; } +/// High-level in-app message category (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +enum InAppMessageCategoryAndroid { + /// Unknown in-app message category. + UnknownInAppMessageCategoryId('unknown-in-app-message-category-id'), + /// Transactional billing messages, such as payment issues or pending price-change confirmations. + Transactional('transactional'); + + const InAppMessageCategoryAndroid(this.value); + final String value; + + factory InAppMessageCategoryAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'unknown-in-app-message-category-id': + return InAppMessageCategoryAndroid.UnknownInAppMessageCategoryId; + case 'transactional': + return InAppMessageCategoryAndroid.Transactional; + } + throw ArgumentError('Unknown InAppMessageCategoryAndroid value: $value'); + } + + String toJson() => value; +} + +/// Response code from Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +enum InAppMessageResponseCodeAndroid { + /// Flow finished and no developer action is needed. + NoActionNeeded('no-action-needed'), + /// Subscription status changed and the purchase token should be checked. + SubscriptionStatusUpdated('subscription-status-updated'); + + const InAppMessageResponseCodeAndroid(this.value); + final String value; + + factory InAppMessageResponseCodeAndroid.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'no-action-needed': + return InAppMessageResponseCodeAndroid.NoActionNeeded; + case 'subscription-status-updated': + return InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated; + } + throw ArgumentError('Unknown InAppMessageResponseCodeAndroid value: $value'); + } + + String toJson() => value; +} + /// Payment mode for subscription offers. /// Determines how the user pays during the offer period. enum PaymentMode { @@ -1229,6 +1377,7 @@ abstract class PurchaseCommon { int get quantity; /// Store where purchase was made IapStore get store; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. double get transactionDate; } @@ -1270,6 +1419,7 @@ class ActiveSubscription { /// Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, /// pending upgrades/downgrades, and auto-renewal preferences. final RenewalInfoIOS? renewalInfoIOS; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String transactionId; /// @deprecated iOS only - use daysUntilExpirationIOS instead. @@ -1527,23 +1677,63 @@ class AppTransaction { } } +/// Display information for developer-rendered Billing Choice screens (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class BillingChoiceInfoAndroid { + const BillingChoiceInfoAndroid({ + required this.playBillingChoiceImageUrl, + this.playBillingLoyaltyInfo, + }); + + /// URL for the Play Billing choice image matching the requested layout. + final String playBillingChoiceImageUrl; + /// Play Loyalty information for the user. + final String? playBillingLoyaltyInfo; + + factory BillingChoiceInfoAndroid.fromJson(Map json) { + return BillingChoiceInfoAndroid( + playBillingChoiceImageUrl: json['playBillingChoiceImageUrl'] as String, + playBillingLoyaltyInfo: json['playBillingLoyaltyInfo'] as String?, + ); + } + + Map toJson() { + return { + '__typename': 'BillingChoiceInfoAndroid', + 'playBillingChoiceImageUrl': playBillingChoiceImageUrl, + 'playBillingLoyaltyInfo': playBillingLoyaltyInfo, + }; + } +} + /// Result of checking billing program availability (Android) /// Available in Google Play Billing Library 8.2.0+ class BillingProgramAvailabilityResultAndroid { const BillingProgramAvailabilityResultAndroid({ required this.billingProgram, + this.choiceScreenType, required this.isAvailable, + this.isExternalLinkAvailable, }); /// The billing program that was checked final BillingProgramAndroid billingProgram; + /// Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + final BillingChoiceScreenTypeAndroid? choiceScreenType; /// Whether the billing program is available for the user final bool isAvailable; + /// Whether external-link payment is available for Billing Choice. + /// Populated only for available BILLING_CHOICE results. + /// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + final bool? isExternalLinkAvailable; factory BillingProgramAvailabilityResultAndroid.fromJson(Map json) { return BillingProgramAvailabilityResultAndroid( billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), + choiceScreenType: json['choiceScreenType'] != null ? BillingChoiceScreenTypeAndroid.fromJson(json['choiceScreenType'] as String) : null, isAvailable: json['isAvailable'] as bool, + isExternalLinkAvailable: json['isExternalLinkAvailable'] as bool?, ); } @@ -1551,7 +1741,9 @@ class BillingProgramAvailabilityResultAndroid { return { '__typename': 'BillingProgramAvailabilityResultAndroid', 'billingProgram': billingProgram.toJson(), + 'choiceScreenType': choiceScreenType?.toJson(), 'isAvailable': isAvailable, + 'isExternalLinkAvailable': isExternalLinkAvailable, }; } } @@ -1627,17 +1819,30 @@ class BillingResultAndroid { /// Available in Google Play Billing Library 8.3.0+ class DeveloperProvidedBillingDetailsAndroid { const DeveloperProvidedBillingDetailsAndroid({ - required this.externalTransactionToken, + this.externalTransactionToken, + this.linkUri, + this.originalExternalTransactionId, + required this.products, }); /// External transaction token used to report transactions made through developer billing. - /// This token must be used when reporting the external transaction to Google Play. - /// Must be reported within 24 hours of the transaction. - final String externalTransactionToken; + /// Nullable for flows such as external payments where no token is returned. + final String? externalTransactionToken; + /// URI to launch for an external-link Billing Choice flow, when provided by + /// Google Play. + final String? linkUri; + /// Original external transaction ID when replacing a subscription that was + /// purchased through developer billing. + final String? originalExternalTransactionId; + /// Products selected for the developer billing flow. + final List products; factory DeveloperProvidedBillingDetailsAndroid.fromJson(Map json) { return DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken: json['externalTransactionToken'] as String, + externalTransactionToken: json['externalTransactionToken'] as String?, + linkUri: json['linkUri'] as String?, + originalExternalTransactionId: json['originalExternalTransactionId'] as String?, + products: (json['products'] as List).map((e) => DeveloperProvidedBillingProductAndroid.fromJson(e as Map)).toList(), ); } @@ -1645,6 +1850,42 @@ class DeveloperProvidedBillingDetailsAndroid { return { '__typename': 'DeveloperProvidedBillingDetailsAndroid', 'externalTransactionToken': externalTransactionToken, + 'linkUri': linkUri, + 'originalExternalTransactionId': originalExternalTransactionId, + 'products': products.map((e) => e.toJson()).toList(), + }; + } +} + +/// Product selected for developer-provided billing (Android 9.0+). +class DeveloperProvidedBillingProductAndroid { + const DeveloperProvidedBillingProductAndroid({ + required this.id, + this.offerToken, + required this.type, + }); + + /// Product identifier. + final String id; + /// Subscription offer token, when applicable. + final String? offerToken; + /// Google Play product type (in-app or subscription). + final ProductType type; + + factory DeveloperProvidedBillingProductAndroid.fromJson(Map json) { + return DeveloperProvidedBillingProductAndroid( + id: json['id'] as String, + offerToken: json['offerToken'] as String?, + type: ProductType.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'DeveloperProvidedBillingProductAndroid', + 'id': id, + 'offerToken': offerToken, + 'type': type.toJson(), }; } } @@ -2141,6 +2382,36 @@ class FetchProductsResultSubscriptions extends FetchProductsResult { final List? value; } +/// Result from showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +class InAppMessageResultAndroid { + const InAppMessageResultAndroid({ + this.purchaseToken, + required this.responseCode, + }); + + /// Purchase token returned when a subscription status changed. + final String? purchaseToken; + /// Response code for the in-app messaging flow. + final InAppMessageResponseCodeAndroid responseCode; + + factory InAppMessageResultAndroid.fromJson(Map json) { + return InAppMessageResultAndroid( + purchaseToken: json['purchaseToken'] as String?, + responseCode: InAppMessageResponseCodeAndroid.fromJson(json['responseCode'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'InAppMessageResultAndroid', + 'purchaseToken': purchaseToken, + 'responseCode': responseCode.toJson(), + }; + } +} + /// Installment plan details for subscription offers (Android) /// Contains information about the installment plan commitment. /// Available in Google Play Billing Library 7.0+ @@ -2941,6 +3212,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { final String? signatureAndroid; /// Store where purchase was made final IapStore store; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String? transactionId; final bool? isAlternativeBilling; @@ -3134,6 +3406,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { final IapStore store; final String? storefrontCountryCodeIOS; final String? subscriptionGroupIdIOS; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String transactionId; final String? transactionReasonIOS; @@ -4292,6 +4565,34 @@ class AndroidSubscriptionOfferInput { } } +/// Parameters for showing a billing program information dialog (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class BillingProgramInformationDialogParamsAndroid { + const BillingProgramInformationDialogParamsAndroid({ + this.billingProgram = BillingProgramAndroid.BillingChoice, + required this.externalTransactionToken, + }); + + /// Billing program. Currently only BILLING_CHOICE is supported. + final BillingProgramAndroid billingProgram; + /// External transaction token returned by the Billing Choice reporting-details flow. + final String externalTransactionToken; + + factory BillingProgramInformationDialogParamsAndroid.fromJson(Map json) { + return BillingProgramInformationDialogParamsAndroid( + billingProgram: json['billingProgram'] != null ? BillingProgramAndroid.fromJson(json['billingProgram'] as String) : BillingProgramAndroid.BillingChoice, + externalTransactionToken: json['externalTransactionToken'] as String, + ); + } + + Map toJson() { + return { + 'billingProgram': billingProgram.toJson(), + 'externalTransactionToken': externalTransactionToken, + }; + } +} + class DeepLinkOptions { const DeepLinkOptions({ this.packageNameAndroid, @@ -4318,35 +4619,45 @@ class DeepLinkOptions { } } -/// Parameters for developer billing option in purchase flow (Android) -/// Used with BillingFlowParams to enable external payments flow -/// Available in Google Play Billing Library 8.3.0+ +/// Parameters for a developer billing option in a purchase flow (Android). +/// Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice +/// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). +/// Only billingProgram is required; link fields are used when the selected program +/// links outside the app. class DeveloperBillingOptionParamsAndroid { const DeveloperBillingOptionParamsAndroid({ required this.billingProgram, - required this.launchMode, - required this.linkUri, + this.externalTransactionToken, + this.launchMode, + this.linkUri, }); - /// The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + /// The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. final BillingProgramAndroid billingProgram; - /// The launch mode for the external payment link - final DeveloperBillingLaunchModeAndroid launchMode; - /// The URI where the external payment will be processed - final String linkUri; + /// A pre-generated external transaction token for a Billing Choice external-link + /// flow. Omit it when Google Play should provide the token in the callback. + final String? externalTransactionToken; + /// The launch mode for the external payment link. + /// Required only when the selected billing program links outside the app. + final DeveloperBillingLaunchModeAndroid? launchMode; + /// The URI where the external payment will be processed. + /// Required only when the selected billing program links outside the app. + final String? linkUri; factory DeveloperBillingOptionParamsAndroid.fromJson(Map json) { return DeveloperBillingOptionParamsAndroid( billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), - launchMode: DeveloperBillingLaunchModeAndroid.fromJson(json['launchMode'] as String), - linkUri: json['linkUri'] as String, + externalTransactionToken: json['externalTransactionToken'] as String?, + launchMode: json['launchMode'] != null ? DeveloperBillingLaunchModeAndroid.fromJson(json['launchMode'] as String) : null, + linkUri: json['linkUri'] as String?, ); } Map toJson() { return { 'billingProgram': billingProgram.toJson(), - 'launchMode': launchMode.toJson(), + 'externalTransactionToken': externalTransactionToken, + 'launchMode': launchMode?.toJson(), 'linkUri': linkUri, }; } @@ -4393,10 +4704,68 @@ class DiscountOfferInputIOS { } } +/// Parameters for fetching Billing Choice display information (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class GetBillingChoiceInfoParamsAndroid { + const GetBillingChoiceInfoParamsAndroid({ + this.billingProgram = BillingProgramAndroid.BillingChoice, + this.playBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.RectangularFourByOne, + this.userLocale, + }); + + /// Billing program. Currently only BILLING_CHOICE is supported. + final BillingProgramAndroid billingProgram; + /// Desired Play Billing choice image layout. + final BillingChoiceImageLayoutAndroid playBillingChoiceImageLayout; + /// BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + final String? userLocale; + + factory GetBillingChoiceInfoParamsAndroid.fromJson(Map json) { + return GetBillingChoiceInfoParamsAndroid( + billingProgram: json['billingProgram'] != null ? BillingProgramAndroid.fromJson(json['billingProgram'] as String) : BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout: json['playBillingChoiceImageLayout'] != null ? BillingChoiceImageLayoutAndroid.fromJson(json['playBillingChoiceImageLayout'] as String) : BillingChoiceImageLayoutAndroid.RectangularFourByOne, + userLocale: json['userLocale'] as String?, + ); + } + + Map toJson() { + return { + 'billingProgram': billingProgram.toJson(), + 'playBillingChoiceImageLayout': playBillingChoiceImageLayout.toJson(), + 'userLocale': userLocale, + }; + } +} + +/// Parameters for showing Play billing in-app messages (Android) +/// Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +/// (upstream API available since Play Billing 4.1.0). +class InAppMessageParamsAndroid { + const InAppMessageParamsAndroid({ + this.categories = const [InAppMessageCategoryAndroid.Transactional], + }); + + /// In-app message categories to show. Defaults to transactional messages. + final List? categories; + + factory InAppMessageParamsAndroid.fromJson(Map json) { + return InAppMessageParamsAndroid( + categories: (json['categories'] as List?) == null ? const [InAppMessageCategoryAndroid.Transactional] : (json['categories'] as List?)!.map((e) => InAppMessageCategoryAndroid.fromJson(e as String)).toList(), + ); + } + + Map toJson() { + return { + 'categories': categories == null ? null : categories!.map((e) => e.toJson()).toList(), + }; + } +} + /// Connection initialization configuration class InitConnectionConfig { const InitConnectionConfig({ this.alternativeBillingModeAndroid, + this.billingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GoogleRendered, this.enableBillingProgramAndroid, }); @@ -4405,17 +4774,28 @@ class InitConnectionConfig { /// @deprecated Use enableBillingProgramAndroid instead. /// Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. final AlternativeBillingModeAndroid? alternativeBillingModeAndroid; + /// Billing Choice renderer configured in Play Console. Available in OpenIAP + /// Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + /// can emit the selection event. DEVELOPER_RENDERED omits that listener so the + /// app can render its own choice screen and use the reporting/dialog/link APIs. + /// Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + /// Defaults to GOOGLE_RENDERED. + final BillingChoiceScreenTypeAndroid? billingChoiceScreenTypeAndroid; /// Enable a specific billing program for Android (7.0+) /// When set, enables the specified billing program for external transactions. /// - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) /// - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) /// - EXTERNAL_OFFER: External offers for digital content (8.2.0+) /// - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + /// - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + /// (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) final BillingProgramAndroid? enableBillingProgramAndroid; factory InitConnectionConfig.fromJson(Map json) { return InitConnectionConfig( alternativeBillingModeAndroid: json['alternativeBillingModeAndroid'] != null ? AlternativeBillingModeAndroid.fromJson(json['alternativeBillingModeAndroid'] as String) : null, + billingChoiceScreenTypeAndroid: json['billingChoiceScreenTypeAndroid'] != null ? BillingChoiceScreenTypeAndroid.fromJson(json['billingChoiceScreenTypeAndroid'] as String) : BillingChoiceScreenTypeAndroid.GoogleRendered, enableBillingProgramAndroid: json['enableBillingProgramAndroid'] != null ? BillingProgramAndroid.fromJson(json['enableBillingProgramAndroid'] as String) : null, ); } @@ -4423,24 +4803,31 @@ class InitConnectionConfig { Map toJson() { return { 'alternativeBillingModeAndroid': alternativeBillingModeAndroid?.toJson(), + 'billingChoiceScreenTypeAndroid': billingChoiceScreenTypeAndroid?.toJson(), 'enableBillingProgramAndroid': enableBillingProgramAndroid?.toJson(), }; } } /// Parameters for launching an external link (Android) -/// Used with launchExternalLink to initiate external offer or app install flows +/// Used with launchExternalLink to initiate external offer, app install, or +/// developer-rendered Billing Choice flows /// Available in Google Play Billing Library 8.2.0+ class LaunchExternalLinkParamsAndroid { const LaunchExternalLinkParamsAndroid({ required this.billingProgram, + this.externalTransactionToken, required this.launchMode, required this.linkType, required this.linkUri, }); - /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + /// The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) final BillingProgramAndroid billingProgram; + /// External transaction token for a developer-rendered Billing Choice external-link + /// flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + final String? externalTransactionToken; /// The external link launch mode final ExternalLinkLaunchModeAndroid launchMode; /// The type of the external link @@ -4451,6 +4838,7 @@ class LaunchExternalLinkParamsAndroid { factory LaunchExternalLinkParamsAndroid.fromJson(Map json) { return LaunchExternalLinkParamsAndroid( billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), + externalTransactionToken: json['externalTransactionToken'] as String?, launchMode: ExternalLinkLaunchModeAndroid.fromJson(json['launchMode'] as String), linkType: ExternalLinkTypeAndroid.fromJson(json['linkType'] as String), linkUri: json['linkUri'] as String, @@ -4460,6 +4848,7 @@ class LaunchExternalLinkParamsAndroid { Map toJson() { return { 'billingProgram': billingProgram.toJson(), + 'externalTransactionToken': externalTransactionToken, 'launchMode': launchMode.toJson(), 'linkType': linkType.toJson(), 'linkUri': linkUri, @@ -4470,7 +4859,7 @@ class LaunchExternalLinkParamsAndroid { class ProductRequest { const ProductRequest({ required this.skus, - this.type, + this.type = ProductQueryType.InApp, }); final List skus; @@ -4479,7 +4868,7 @@ class ProductRequest { factory ProductRequest.fromJson(Map json) { return ProductRequest( skus: (json['skus'] as List).map((e) => e as String).toList(), - type: json['type'] != null ? ProductQueryType.fromJson(json['type'] as String) : null, + type: json['type'] != null ? ProductQueryType.fromJson(json['type'] as String) : ProductQueryType.InApp, ); } @@ -4592,9 +4981,9 @@ class RequestPurchaseAndroidProps { required this.skus, }); - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). final DeveloperBillingOptionParamsAndroid? developerBillingOption; /// Personalized offer flag. /// When true, indicates the price was customized for this user. @@ -4792,6 +5181,7 @@ class RequestSubscriptionAndroidProps { this.isOfferPersonalized, this.obfuscatedAccountId, this.obfuscatedProfileId, + this.originalExternalTransactionId, this.purchaseToken, this.replacementMode, required this.skus, @@ -4799,9 +5189,9 @@ class RequestSubscriptionAndroidProps { this.subscriptionProductReplacementParams, }); - /// Developer billing option parameters for external payments flow (8.3.0+). - /// When provided, the purchase flow will show a side-by-side choice between - /// Google Play Billing and the developer's external payment option. + /// Developer billing option parameters for external payments and Billing Choice. + /// Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). final DeveloperBillingOptionParamsAndroid? developerBillingOption; /// Personalized offer flag. /// When true, indicates the price was customized for this user. @@ -4810,6 +5200,10 @@ class RequestSubscriptionAndroidProps { final String? obfuscatedAccountId; /// Obfuscated profile ID final String? obfuscatedProfileId; + /// Original external transaction ID for replacing a subscription that was + /// purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). + final String? originalExternalTransactionId; /// Purchase token for upgrades/downgrades final String? purchaseToken; /// Replacement mode for subscription changes @@ -4829,6 +5223,7 @@ class RequestSubscriptionAndroidProps { isOfferPersonalized: json['isOfferPersonalized'] as bool?, obfuscatedAccountId: json['obfuscatedAccountId'] as String?, obfuscatedProfileId: json['obfuscatedProfileId'] as String?, + originalExternalTransactionId: json['originalExternalTransactionId'] as String?, purchaseToken: json['purchaseToken'] as String?, replacementMode: json['replacementMode'] as int?, skus: (json['skus'] as List).map((e) => e as String).toList(), @@ -4843,6 +5238,7 @@ class RequestSubscriptionAndroidProps { 'isOfferPersonalized': isOfferPersonalized, 'obfuscatedAccountId': obfuscatedAccountId, 'obfuscatedProfileId': obfuscatedProfileId, + 'originalExternalTransactionId': originalExternalTransactionId, 'purchaseToken': purchaseToken, 'replacementMode': replacementMode, 'skus': skus, @@ -5460,6 +5856,7 @@ sealed class Purchase implements PurchaseCommon { /// Store where purchase was made @override IapStore get store; + /// Unix timestamp in milliseconds since January 1, 1970 UTC. @override double get transactionDate; @@ -5496,7 +5893,6 @@ abstract class MutationResolver { /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios Future beginRefundRequestIOS(String sku); /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -5510,18 +5906,23 @@ abstract class MutationResolver { /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. /// Token must be reported to Google Play backend within 24 hours. - /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android Future createAlternativeBillingTokenAndroid(); /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. - /// /// Returns external transaction token needed for reporting external transactions. + /// developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + /// native Android defaults it to IN_APP. + /// The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android - Future createBillingProgramReportingDetailsAndroid(BillingProgramAndroid program); + Future createBillingProgramReportingDetailsAndroid({ + required BillingProgramAndroid program, + DeveloperBillingTypeAndroid? developerBillingType, + }); /// Open the platform's subscription management UI. /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions Future deepLinkToSubscriptions({ @@ -5541,24 +5942,27 @@ abstract class MutationResolver { /// See: https://openiap.dev/docs/apis/init-connection Future initConnection({ AlternativeBillingModeAndroid? alternativeBillingModeAndroid, + BillingChoiceScreenTypeAndroid? billingChoiceScreenTypeAndroid, BillingProgramAndroid? enableBillingProgramAndroid, }); /// Check whether a billing program (e.g., External Payments) is available for the current user. /// Replaces the deprecated isExternalOfferAvailableAsync API. - /// /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android Future isBillingProgramAvailableAndroid(BillingProgramAndroid program); - /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + /// including developer-rendered Billing Choice external-link flows. + /// Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + /// (requires Play Billing 9.1.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. - /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/launch-external-link-android Future launchExternalLinkAndroid({ required BillingProgramAndroid billingProgram, + String? externalTransactionToken, required ExternalLinkLaunchModeAndroid launchMode, required ExternalLinkTypeAndroid linkType, required String linkUri, @@ -5589,16 +5993,33 @@ abstract class MutationResolver { Future restorePurchases(); /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. - /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android Future showAlternativeBillingDialogAndroid(); + /// Show Google's mandatory information dialog before a developer-rendered, + /// in-app Billing Choice screen. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + Future showBillingProgramInformationDialogAndroid({ + required BillingProgramAndroid billingProgram, + required String externalTransactionToken, + }); /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios Future showExternalPurchaseCustomLinkNoticeIOS(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); + /// Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + /// (upstream API available since Play Billing 4.1.0). + /// Returns a response code and, when the subscription status changes, the related purchase token. + /// Throws OpenIapError.NotPrepared if billing client not ready. + /// See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + Future showInAppMessagesAndroid({ + List? categories, + }); /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios Future> showManageSubscriptionsIOS(); @@ -5667,6 +6088,15 @@ abstract class QueryResolver { bool? includeSuspendedAndroid, bool? onlyIncludeActiveItemsIOS, }); + /// Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + /// OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + /// Throws OpenIapError.NotPrepared if billing client is not ready. + /// See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + Future getBillingChoiceInfoAndroid({ + required BillingProgramAndroid billingProgram, + required BillingChoiceImageLayoutAndroid playBillingChoiceImageLayout, + String? userLocale, + }); /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) @@ -5721,11 +6151,11 @@ abstract class QueryResolver { /// GraphQL root subscription operations. abstract class SubscriptionResolver { - /// Fires when a user selects developer billing in the External Payments flow (Android only) - /// Triggered when the user chooses to pay via the developer's external payment option - /// instead of Google Play Billing in the side-by-side choice dialog. - /// Contains the externalTransactionToken needed to report the transaction. - /// Available in Google Play Billing Library 8.3.0+ + /// Fires when a user selects developer billing in an External Payments or + /// Billing Choice flow (Android only). The payload can contain an external + /// transaction token, link URI, original transaction ID, and selected products. + /// Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + /// openiap-google 2.3.0 (requires Play Billing 9.1.0+). Future developerProvidedBillingAndroid(); /// Fires when the App Store surfaces a promoted product (iOS only) Future promotedProductIOS(); @@ -5765,7 +6195,10 @@ typedef MutationCheckAlternativeBillingAvailabilityAndroidHandler = Future typedef MutationClearTransactionIOSHandler = Future Function(); typedef MutationConsumePurchaseAndroidHandler = Future Function(String purchaseToken); typedef MutationCreateAlternativeBillingTokenAndroidHandler = Future Function(); -typedef MutationCreateBillingProgramReportingDetailsAndroidHandler = Future Function(BillingProgramAndroid program); +typedef MutationCreateBillingProgramReportingDetailsAndroidHandler = Future Function({ + required BillingProgramAndroid program, + DeveloperBillingTypeAndroid? developerBillingType, +}); typedef MutationDeepLinkToSubscriptionsHandler = Future Function({ String? packageNameAndroid, String? skuAndroid, @@ -5777,11 +6210,13 @@ typedef MutationFinishTransactionHandler = Future Function({ }); typedef MutationInitConnectionHandler = Future Function({ AlternativeBillingModeAndroid? alternativeBillingModeAndroid, + BillingChoiceScreenTypeAndroid? billingChoiceScreenTypeAndroid, BillingProgramAndroid? enableBillingProgramAndroid, }); typedef MutationIsBillingProgramAvailableAndroidHandler = Future Function(BillingProgramAndroid program); typedef MutationLaunchExternalLinkAndroidHandler = Future Function({ required BillingProgramAndroid billingProgram, + String? externalTransactionToken, required ExternalLinkLaunchModeAndroid launchMode, required ExternalLinkTypeAndroid linkType, required String linkUri, @@ -5793,7 +6228,14 @@ typedef MutationRequestPurchaseHandler = Future Function typedef MutationRequestPurchaseOnPromotedProductIOSHandler = Future Function(); typedef MutationRestorePurchasesHandler = Future Function(); typedef MutationShowAlternativeBillingDialogAndroidHandler = Future Function(); +typedef MutationShowBillingProgramInformationDialogAndroidHandler = Future Function({ + required BillingProgramAndroid billingProgram, + required String externalTransactionToken, +}); typedef MutationShowExternalPurchaseCustomLinkNoticeIOSHandler = Future Function(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); +typedef MutationShowInAppMessagesAndroidHandler = Future Function({ + List? categories, +}); typedef MutationShowManageSubscriptionsIOSHandler = Future> Function(); typedef MutationSyncIOSHandler = Future Function(); typedef MutationValidateReceiptHandler = Future Function({ @@ -5833,7 +6275,9 @@ class MutationHandlers { this.requestPurchaseOnPromotedProductIOS, this.restorePurchases, this.showAlternativeBillingDialogAndroid, + this.showBillingProgramInformationDialogAndroid, this.showExternalPurchaseCustomLinkNoticeIOS, + this.showInAppMessagesAndroid, this.showManageSubscriptionsIOS, this.syncIOS, this.validateReceipt, @@ -5861,7 +6305,9 @@ class MutationHandlers { final MutationRequestPurchaseOnPromotedProductIOSHandler? requestPurchaseOnPromotedProductIOS; final MutationRestorePurchasesHandler? restorePurchases; final MutationShowAlternativeBillingDialogAndroidHandler? showAlternativeBillingDialogAndroid; + final MutationShowBillingProgramInformationDialogAndroidHandler? showBillingProgramInformationDialogAndroid; final MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? showExternalPurchaseCustomLinkNoticeIOS; + final MutationShowInAppMessagesAndroidHandler? showInAppMessagesAndroid; final MutationShowManageSubscriptionsIOSHandler? showManageSubscriptionsIOS; final MutationSyncIOSHandler? syncIOS; final MutationValidateReceiptHandler? validateReceipt; @@ -5885,6 +6331,11 @@ typedef QueryGetAvailablePurchasesHandler = Future> Function({ bool? includeSuspendedAndroid, bool? onlyIncludeActiveItemsIOS, }); +typedef QueryGetBillingChoiceInfoAndroidHandler = Future Function({ + required BillingProgramAndroid billingProgram, + required BillingChoiceImageLayoutAndroid playBillingChoiceImageLayout, + String? userLocale, +}); typedef QueryGetExternalPurchaseCustomLinkTokenIOSHandler = Future Function(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); typedef QueryGetPendingTransactionsIOSHandler = Future> Function(); typedef QueryGetPromotedProductIOSHandler = Future Function(); @@ -5913,6 +6364,7 @@ class QueryHandlers { this.getAllTransactionsIOS, this.getAppTransactionIOS, this.getAvailablePurchases, + this.getBillingChoiceInfoAndroid, this.getExternalPurchaseCustomLinkTokenIOS, this.getPendingTransactionsIOS, this.getPromotedProductIOS, @@ -5936,6 +6388,7 @@ class QueryHandlers { final QueryGetAllTransactionsIOSHandler? getAllTransactionsIOS; final QueryGetAppTransactionIOSHandler? getAppTransactionIOS; final QueryGetAvailablePurchasesHandler? getAvailablePurchases; + final QueryGetBillingChoiceInfoAndroidHandler? getBillingChoiceInfoAndroid; final QueryGetExternalPurchaseCustomLinkTokenIOSHandler? getExternalPurchaseCustomLinkTokenIOS; final QueryGetPendingTransactionsIOSHandler? getPendingTransactionsIOS; final QueryGetPromotedProductIOSHandler? getPromotedProductIOS; diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index f36b8f261..28193ce4e 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -21,7 +21,27 @@ enum AlternativeBillingModeAndroid { ALTERNATIVE_ONLY = 2, } -## Billing program types for external content links, external offers, and external payments (Android) Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +## Play Billing choice image layout (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum BillingChoiceImageLayoutAndroid { + ## Rectangular image with a 4:1 aspect ratio. + RECTANGULAR_FOUR_BY_ONE = 0, + ## Rectangular image with a 3:1 aspect ratio. + RECTANGULAR_THREE_BY_ONE = 1, + ## Rectangular image with a 2:2 aspect ratio. + RECTANGULAR_TWO_BY_TWO = 2, +} + +## Choice screen renderer for Billing Choice availability (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum BillingChoiceScreenTypeAndroid { + ## Unspecified choice screen type. + UNSPECIFIED = 0, + ## Choice screen is rendered by the developer app. + DEVELOPER_RENDERED = 1, + ## Choice screen is rendered by Google Play. + GOOGLE_RENDERED = 2, +} + +## Billing program types for Google Play Billing Programs (Android) Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). enum BillingProgramAndroid { ## Unspecified billing program. Do not use. UNSPECIFIED = 0, @@ -33,6 +53,8 @@ enum BillingProgramAndroid { EXTERNAL_OFFER = 3, ## External Payments program (Japan only). Allows presenting a side-by-side choice between Google Play Billing and developer's external payment option. Users can choose to complete the purchase on the developer's website. Available in Google Play Billing Library 8.3.0+ EXTERNAL_PAYMENTS = 4, + ## Billing Choice program. Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + BILLING_CHOICE = 5, } ## Launch mode for developer billing option (Android) Determines how the external payment URL is launched Available in Google Play Billing Library 8.3.0+ @@ -45,6 +67,16 @@ enum DeveloperBillingLaunchModeAndroid { CALLER_WILL_LAUNCH_LINK = 2, } +## Developer-provided billing destination type for Billing Program reporting details (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +enum DeveloperBillingTypeAndroid { + ## Unspecified developer billing type. Do not use. + DEVELOPER_BILLING_TYPE_UNSPECIFIED = 0, + ## Developer-provided billing via native in-app experience. + IN_APP = 1, + ## Developer-provided billing via external link or embedded web browsing. + EXTERNAL_LINK = 2, +} + ## Discount offer type enumeration. Categorizes the type of discount or promotional offer. enum DiscountOfferType { ## Introductory offer for new subscribers (first-time purchase discount) @@ -144,7 +176,7 @@ enum IapEvent { PURCHASE_ERROR = 1, PROMOTED_PRODUCT_IOS = 2, USER_CHOICE_BILLING_ANDROID = 3, - ## Fired when user selects developer-provided billing option in external payments flow. Available on Android with Google Play Billing Library 8.3.0+ + ## Fired for External Payments (8.3.0+) and Google-rendered Billing Choice developer billing selections on Android. Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). DEVELOPER_PROVIDED_BILLING_ANDROID = 4, ## Fired when an active subscription enters a billing-issue state that requires user attention. Cross-platform unification of StoreKit 2 Message.billingIssue (iOS 18+) and Play Billing 8.1+ isSuspended. NOT emitted on the Horizon flavor, whose Billing Compatibility SDK implements only the Play Billing 7.0 API surface. SUBSCRIPTION_BILLING_ISSUE = 5, @@ -185,6 +217,22 @@ enum IapStore { AMAZON = 4, } +## High-level in-app message category (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). +enum InAppMessageCategoryAndroid { + ## Unknown in-app message category. + UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID = 0, + ## Transactional billing messages, such as payment issues or pending price-change confirmations. + TRANSACTIONAL = 1, +} + +## Response code from Play billing in-app messages (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). +enum InAppMessageResponseCodeAndroid { + ## Flow finished and no developer action is needed. + NO_ACTION_NEEDED = 0, + ## Subscription status changed and the purchase token should be checked. + SUBSCRIPTION_STATUS_UPDATED = 1, +} + ## Payment mode for subscription offers. Determines how the user pays during the offer period. enum PaymentMode { ## Free trial period - no charge during offer @@ -388,6 +436,7 @@ class ActiveSubscription: var days_until_expiration_ios: Variant = null var transaction_id: String = "" var purchase_token: Variant = null + ## Unix timestamp in milliseconds since January 1, 1970 UTC. var transaction_date: float = 0.0 var base_plan_id_android: Variant = null ## Required for subscription upgrade/downgrade on Android @@ -684,12 +733,38 @@ class AppTransaction: dict["originalPlatform"] = original_platform return dict +## Display information for developer-rendered Billing Choice screens (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class BillingChoiceInfoAndroid: + ## URL for the Play Billing choice image matching the requested layout. + var play_billing_choice_image_url: String = "" + ## Play Loyalty information for the user. + var play_billing_loyalty_info: Variant = null + + static func from_dict(data: Dictionary) -> BillingChoiceInfoAndroid: + var obj = BillingChoiceInfoAndroid.new() + if data.has("playBillingChoiceImageUrl") and data["playBillingChoiceImageUrl"] != null: + obj.play_billing_choice_image_url = data["playBillingChoiceImageUrl"] + if data.has("playBillingLoyaltyInfo") and data["playBillingLoyaltyInfo"] != null: + obj.play_billing_loyalty_info = data["playBillingLoyaltyInfo"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["playBillingChoiceImageUrl"] = play_billing_choice_image_url + if play_billing_loyalty_info != null: + dict["playBillingLoyaltyInfo"] = play_billing_loyalty_info + return dict + ## Result of checking billing program availability (Android) Available in Google Play Billing Library 8.2.0+ class BillingProgramAvailabilityResultAndroid: ## Whether the billing program is available for the user var is_available: bool = false ## The billing program that was checked var billing_program: BillingProgramAndroid + ## Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + var choice_screen_type: BillingChoiceScreenTypeAndroid + ## Whether external-link payment is available for Billing Choice. + var is_external_link_available: Variant = null static func from_dict(data: Dictionary) -> BillingProgramAvailabilityResultAndroid: var obj = BillingProgramAvailabilityResultAndroid.new() @@ -701,6 +776,14 @@ class BillingProgramAvailabilityResultAndroid: obj.billing_program = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] else: obj.billing_program = enum_str + if data.has("choiceScreenType") and data["choiceScreenType"] != null: + var enum_str = data["choiceScreenType"] + if enum_str is String and BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING.has(enum_str): + obj.choice_screen_type = BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING[enum_str] + else: + obj.choice_screen_type = enum_str + if data.has("isExternalLinkAvailable") and data["isExternalLinkAvailable"] != null: + obj.is_external_link_available = data["isExternalLinkAvailable"] return obj func to_dict() -> Dictionary: @@ -710,6 +793,12 @@ class BillingProgramAvailabilityResultAndroid: dict["billingProgram"] = BILLING_PROGRAM_ANDROID_VALUES[billing_program] else: dict["billingProgram"] = billing_program + if BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES.has(choice_screen_type): + dict["choiceScreenType"] = BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES[choice_screen_type] + else: + dict["choiceScreenType"] = choice_screen_type + if is_external_link_available != null: + dict["isExternalLinkAvailable"] = is_external_link_available return dict ## Reporting details for transactions made outside of Google Play Billing (Android) Contains the external transaction token needed for reporting Available in Google Play Billing Library 8.2.0+ @@ -777,17 +866,85 @@ class BillingResultAndroid: ## Details provided when user selects developer billing option (Android) Received via DeveloperProvidedBillingListener callback Available in Google Play Billing Library 8.3.0+ class DeveloperProvidedBillingDetailsAndroid: ## External transaction token used to report transactions made through developer billing. - var external_transaction_token: String = "" + var external_transaction_token: Variant = null + ## URI to launch for an external-link Billing Choice flow, when provided by + var link_uri: Variant = null + ## Original external transaction ID when replacing a subscription that was + var original_external_transaction_id: Variant = null + ## Products selected for the developer billing flow. + var products: Array[DeveloperProvidedBillingProductAndroid] = [] static func from_dict(data: Dictionary) -> DeveloperProvidedBillingDetailsAndroid: var obj = DeveloperProvidedBillingDetailsAndroid.new() if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: obj.external_transaction_token = data["externalTransactionToken"] + if data.has("linkUri") and data["linkUri"] != null: + obj.link_uri = data["linkUri"] + if data.has("originalExternalTransactionId") and data["originalExternalTransactionId"] != null: + obj.original_external_transaction_id = data["originalExternalTransactionId"] + if data.has("products") and data["products"] != null: + if data["products"] is Array: + var arr: Array[DeveloperProvidedBillingProductAndroid] = [] + for item in data["products"]: + if item is Dictionary: + arr.append(DeveloperProvidedBillingProductAndroid.from_dict(item)) + elif item is DeveloperProvidedBillingProductAndroid: + arr.append(item) + obj.products = arr return obj func to_dict() -> Dictionary: var dict = {} - dict["externalTransactionToken"] = external_transaction_token + if external_transaction_token != null: + dict["externalTransactionToken"] = external_transaction_token + if link_uri != null: + dict["linkUri"] = link_uri + if original_external_transaction_id != null: + dict["originalExternalTransactionId"] = original_external_transaction_id + if products != null: + var arr = [] + for item in products: + if item != null and item.has_method("to_dict"): + arr.append(item.to_dict()) + else: + arr.append(item) + dict["products"] = arr + else: + dict["products"] = null + return dict + +## Product selected for developer-provided billing (Android 9.0+). +class DeveloperProvidedBillingProductAndroid: + ## Product identifier. + var id: String = "" + ## Google Play product type (in-app or subscription). + var type: ProductType + ## Subscription offer token, when applicable. + var offer_token: Variant = null + + static func from_dict(data: Dictionary) -> DeveloperProvidedBillingProductAndroid: + var obj = DeveloperProvidedBillingProductAndroid.new() + if data.has("id") and data["id"] != null: + obj.id = data["id"] + if data.has("type") and data["type"] != null: + var enum_str = data["type"] + if enum_str is String and PRODUCT_TYPE_FROM_STRING.has(enum_str): + obj.type = PRODUCT_TYPE_FROM_STRING[enum_str] + else: + obj.type = enum_str + if data.has("offerToken") and data["offerToken"] != null: + obj.offer_token = data["offerToken"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["id"] = id + if PRODUCT_TYPE_VALUES.has(type): + dict["type"] = PRODUCT_TYPE_VALUES[type] + else: + dict["type"] = type + if offer_token != null: + dict["offerToken"] = offer_token return dict ## Discount amount details for one-time purchase offers (Android) Available in Google Play Billing Library 7.0+ @@ -1216,6 +1373,35 @@ class ExternalPurchaseNoticeResultIOS: dict["externalPurchaseToken"] = external_purchase_token return dict +## Result from showing Play billing in-app messages (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). +class InAppMessageResultAndroid: + ## Response code for the in-app messaging flow. + var response_code: InAppMessageResponseCodeAndroid + ## Purchase token returned when a subscription status changed. + var purchase_token: Variant = null + + static func from_dict(data: Dictionary) -> InAppMessageResultAndroid: + var obj = InAppMessageResultAndroid.new() + if data.has("responseCode") and data["responseCode"] != null: + var enum_str = data["responseCode"] + if enum_str is String and IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_FROM_STRING.has(enum_str): + obj.response_code = IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_FROM_STRING[enum_str] + else: + obj.response_code = enum_str + if data.has("purchaseToken") and data["purchaseToken"] != null: + obj.purchase_token = data["purchaseToken"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_VALUES.has(response_code): + dict["responseCode"] = IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_VALUES[response_code] + else: + dict["responseCode"] = response_code + if purchase_token != null: + dict["purchaseToken"] = purchase_token + return dict + ## Installment plan details for subscription offers (Android) Contains information about the installment plan commitment. Available in Google Play Billing Library 7.0+ class InstallmentPlanDetailsAndroid: ## Committed payments count after a user signs up for this subscription plan. @@ -2239,6 +2425,7 @@ class PurchaseAndroid: var product_id: String = "" var ids: Array[String] = [] var transaction_id: Variant = null + ## Unix timestamp in milliseconds since January 1, 1970 UTC. var transaction_date: float = 0.0 var purchase_token: Variant = null ## Store where purchase was made @@ -2442,6 +2629,7 @@ class PurchaseIOS: var id: String = "" var product_id: String = "" var ids: Array[String] = [] + ## Unix timestamp in milliseconds since January 1, 1970 UTC. var transaction_date: float = 0.0 var purchase_token: Variant = null ## Store where purchase was made @@ -3836,6 +4024,36 @@ class AndroidSubscriptionOfferInput: dict["offerToken"] = offer_token return dict +## Parameters for showing a billing program information dialog (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class BillingProgramInformationDialogParamsAndroid: + ## Billing program. Currently only BILLING_CHOICE is supported. + var billing_program: BillingProgramAndroid = BillingProgramAndroid.BILLING_CHOICE + ## External transaction token returned by the Billing Choice reporting-details flow. + var external_transaction_token: String = "" + + static func from_dict(data: Dictionary) -> BillingProgramInformationDialogParamsAndroid: + var obj = BillingProgramInformationDialogParamsAndroid.new() + if data.has("billingProgram") and data["billingProgram"] != null: + var enum_str = data["billingProgram"] + if enum_str is String and BILLING_PROGRAM_ANDROID_FROM_STRING.has(enum_str): + obj.billing_program = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] + else: + obj.billing_program = enum_str + if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: + obj.external_transaction_token = data["externalTransactionToken"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if billing_program != null: + if BILLING_PROGRAM_ANDROID_VALUES.has(billing_program): + dict["billingProgram"] = BILLING_PROGRAM_ANDROID_VALUES[billing_program] + else: + dict["billingProgram"] = billing_program + if external_transaction_token != null: + dict["externalTransactionToken"] = external_transaction_token + return dict + class DeepLinkOptions: ## Android SKU to open (required on Android) var sku_android: Variant = null @@ -3858,14 +4076,16 @@ class DeepLinkOptions: dict["packageNameAndroid"] = package_name_android return dict -## Parameters for developer billing option in purchase flow (Android) Used with BillingFlowParams to enable external payments flow Available in Google Play Billing Library 8.3.0+ +## Parameters for a developer billing option in a purchase flow (Android). Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). Only billingProgram is required; link fields are used when the selected program links outside the app. class DeveloperBillingOptionParamsAndroid: - ## The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + ## The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. var billing_program: BillingProgramAndroid - ## The URI where the external payment will be processed - var link_uri: String = "" - ## The launch mode for the external payment link + ## The URI where the external payment will be processed. + var link_uri: Variant = null + ## The launch mode for the external payment link. var launch_mode: DeveloperBillingLaunchModeAndroid + ## A pre-generated external transaction token for a Billing Choice external-link + var external_transaction_token: Variant = null static func from_dict(data: Dictionary) -> DeveloperBillingOptionParamsAndroid: var obj = DeveloperBillingOptionParamsAndroid.new() @@ -3883,6 +4103,8 @@ class DeveloperBillingOptionParamsAndroid: obj.launch_mode = DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_FROM_STRING[enum_str] else: obj.launch_mode = enum_str + if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: + obj.external_transaction_token = data["externalTransactionToken"] return obj func to_dict() -> Dictionary: @@ -3899,6 +4121,8 @@ class DeveloperBillingOptionParamsAndroid: dict["launchMode"] = DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_VALUES[launch_mode] else: dict["launchMode"] = launch_mode + if external_transaction_token != null: + dict["externalTransactionToken"] = external_transaction_token return dict class DiscountOfferInputIOS: @@ -3941,12 +4165,87 @@ class DiscountOfferInputIOS: dict["timestamp"] = timestamp return dict +## Parameters for fetching Billing Choice display information (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +class GetBillingChoiceInfoParamsAndroid: + ## Billing program. Currently only BILLING_CHOICE is supported. + var billing_program: BillingProgramAndroid = BillingProgramAndroid.BILLING_CHOICE + ## Desired Play Billing choice image layout. + var play_billing_choice_image_layout: BillingChoiceImageLayoutAndroid = BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE + ## BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + var user_locale: Variant = null + + static func from_dict(data: Dictionary) -> GetBillingChoiceInfoParamsAndroid: + var obj = GetBillingChoiceInfoParamsAndroid.new() + if data.has("billingProgram") and data["billingProgram"] != null: + var enum_str = data["billingProgram"] + if enum_str is String and BILLING_PROGRAM_ANDROID_FROM_STRING.has(enum_str): + obj.billing_program = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] + else: + obj.billing_program = enum_str + if data.has("playBillingChoiceImageLayout") and data["playBillingChoiceImageLayout"] != null: + var enum_str = data["playBillingChoiceImageLayout"] + if enum_str is String and BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_FROM_STRING.has(enum_str): + obj.play_billing_choice_image_layout = BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_FROM_STRING[enum_str] + else: + obj.play_billing_choice_image_layout = enum_str + if data.has("userLocale") and data["userLocale"] != null: + obj.user_locale = data["userLocale"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + if billing_program != null: + if BILLING_PROGRAM_ANDROID_VALUES.has(billing_program): + dict["billingProgram"] = BILLING_PROGRAM_ANDROID_VALUES[billing_program] + else: + dict["billingProgram"] = billing_program + if play_billing_choice_image_layout != null: + if BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_VALUES.has(play_billing_choice_image_layout): + dict["playBillingChoiceImageLayout"] = BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_VALUES[play_billing_choice_image_layout] + else: + dict["playBillingChoiceImageLayout"] = play_billing_choice_image_layout + if user_locale != null: + dict["userLocale"] = user_locale + return dict + +## Parameters for showing Play billing in-app messages (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). +class InAppMessageParamsAndroid: + ## In-app message categories to show. Defaults to transactional messages. + var categories: Array[InAppMessageCategoryAndroid] = [] + + static func from_dict(data: Dictionary) -> InAppMessageParamsAndroid: + var obj = InAppMessageParamsAndroid.new() + if data.has("categories") and data["categories"] != null: + if data["categories"] is Array: + var arr: Array[InAppMessageCategoryAndroid] = [] + for item in data["categories"]: + if item is String and IN_APP_MESSAGE_CATEGORY_ANDROID_FROM_STRING.has(item): + arr.append(IN_APP_MESSAGE_CATEGORY_ANDROID_FROM_STRING[item]) + elif item is int: + arr.append(item) + obj.categories = arr + return obj + + func to_dict() -> Dictionary: + var dict = {} + if categories != null: + var arr = [] + for item in categories: + if IN_APP_MESSAGE_CATEGORY_ANDROID_VALUES.has(item): + arr.append(IN_APP_MESSAGE_CATEGORY_ANDROID_VALUES[item]) + else: + arr.append(item) + dict["categories"] = arr + return dict + ## Connection initialization configuration class InitConnectionConfig: ## Alternative billing mode for Android var alternative_billing_mode_android: AlternativeBillingModeAndroid ## Enable a specific billing program for Android (7.0+) var enable_billing_program_android: BillingProgramAndroid + ## Billing Choice renderer configured in Play Console. Available in OpenIAP + var billing_choice_screen_type_android: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED static func from_dict(data: Dictionary) -> InitConnectionConfig: var obj = InitConnectionConfig.new() @@ -3962,6 +4261,12 @@ class InitConnectionConfig: obj.enable_billing_program_android = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] else: obj.enable_billing_program_android = enum_str + if data.has("billingChoiceScreenTypeAndroid") and data["billingChoiceScreenTypeAndroid"] != null: + var enum_str = data["billingChoiceScreenTypeAndroid"] + if enum_str is String and BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING.has(enum_str): + obj.billing_choice_screen_type_android = BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING[enum_str] + else: + obj.billing_choice_screen_type_android = enum_str return obj func to_dict() -> Dictionary: @@ -3976,11 +4281,16 @@ class InitConnectionConfig: dict["enableBillingProgramAndroid"] = BILLING_PROGRAM_ANDROID_VALUES[enable_billing_program_android] else: dict["enableBillingProgramAndroid"] = enable_billing_program_android + if billing_choice_screen_type_android != null: + if BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES.has(billing_choice_screen_type_android): + dict["billingChoiceScreenTypeAndroid"] = BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES[billing_choice_screen_type_android] + else: + dict["billingChoiceScreenTypeAndroid"] = billing_choice_screen_type_android return dict -## Parameters for launching an external link (Android) Used with launchExternalLink to initiate external offer or app install flows Available in Google Play Billing Library 8.2.0+ +## Parameters for launching an external link (Android) Used with launchExternalLink to initiate external offer, app install, or developer-rendered Billing Choice flows Available in Google Play Billing Library 8.2.0+ class LaunchExternalLinkParamsAndroid: - ## The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + ## The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) var billing_program: BillingProgramAndroid ## The external link launch mode var launch_mode: ExternalLinkLaunchModeAndroid @@ -3988,6 +4298,8 @@ class LaunchExternalLinkParamsAndroid: var link_type: ExternalLinkTypeAndroid ## The URI where the content will be accessed from var link_uri: String = "" + ## External transaction token for a developer-rendered Billing Choice external-link + var external_transaction_token: Variant = null static func from_dict(data: Dictionary) -> LaunchExternalLinkParamsAndroid: var obj = LaunchExternalLinkParamsAndroid.new() @@ -4011,6 +4323,8 @@ class LaunchExternalLinkParamsAndroid: obj.link_type = enum_str if data.has("linkUri") and data["linkUri"] != null: obj.link_uri = data["linkUri"] + if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: + obj.external_transaction_token = data["externalTransactionToken"] return obj func to_dict() -> Dictionary: @@ -4032,11 +4346,13 @@ class LaunchExternalLinkParamsAndroid: dict["linkType"] = link_type if link_uri != null: dict["linkUri"] = link_uri + if external_transaction_token != null: + dict["externalTransactionToken"] = external_transaction_token return dict class ProductRequest: var skus: Array[String] = [] - var type: ProductQueryType + var type: ProductQueryType = ProductQueryType.IN_APP static func from_dict(data: Dictionary) -> ProductRequest: var obj = ProductRequest.new() @@ -4093,6 +4409,7 @@ class PurchaseInput: var id: String = "" var product_id: String = "" var ids: Array[String] = [] + ## Unix timestamp in milliseconds since January 1, 1970 UTC. var transaction_date: float = 0.0 var purchase_token: Variant = null ## Store where purchase was made @@ -4232,7 +4549,7 @@ class RequestPurchaseAndroidProps: var is_offer_personalized: Variant = null ## Offer token for one-time purchase discounts (7.0+). var offer_token: Variant = null - ## Developer billing option parameters for external payments flow (8.3.0+). + ## Developer billing option parameters for external payments and Billing Choice. var developer_billing_option: DeveloperBillingOptionParamsAndroid static func from_dict(data: Dictionary) -> RequestPurchaseAndroidProps: @@ -4336,7 +4653,7 @@ class RequestPurchaseProps: ## Per-platform subscription request props var request_subscription: RequestSubscriptionPropsByPlatforms ## Explicit purchase type hint (defaults to in-app) - var type: ProductQueryType + var type: ProductQueryType = ProductQueryType.IN_APP ## @deprecated Use enableBillingProgramAndroid in InitConnectionConfig instead. var use_alternative_billing: Variant = null @@ -4453,13 +4770,15 @@ class RequestSubscriptionAndroidProps: var is_offer_personalized: Variant = null ## Purchase token for upgrades/downgrades var purchase_token: Variant = null + ## Original external transaction ID for replacing a subscription that was + var original_external_transaction_id: Variant = null ## Replacement mode for subscription changes var replacement_mode: Variant = null ## Subscription offers var subscription_offers: Array[AndroidSubscriptionOfferInput] = [] ## Product-level replacement parameters (8.1.0+) var subscription_product_replacement_params: SubscriptionProductReplacementParamsAndroid - ## Developer billing option parameters for external payments flow (8.3.0+). + ## Developer billing option parameters for external payments and Billing Choice. var developer_billing_option: DeveloperBillingOptionParamsAndroid static func from_dict(data: Dictionary) -> RequestSubscriptionAndroidProps: @@ -4479,6 +4798,8 @@ class RequestSubscriptionAndroidProps: obj.is_offer_personalized = data["isOfferPersonalized"] if data.has("purchaseToken") and data["purchaseToken"] != null: obj.purchase_token = data["purchaseToken"] + if data.has("originalExternalTransactionId") and data["originalExternalTransactionId"] != null: + obj.original_external_transaction_id = data["originalExternalTransactionId"] if data.has("replacementMode") and data["replacementMode"] != null: obj.replacement_mode = data["replacementMode"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: @@ -4514,6 +4835,8 @@ class RequestSubscriptionAndroidProps: dict["isOfferPersonalized"] = is_offer_personalized if purchase_token != null: dict["purchaseToken"] = purchase_token + if original_external_transaction_id != null: + dict["originalExternalTransactionId"] = original_external_transaction_id if replacement_mode != null: dict["replacementMode"] = replacement_mode if subscription_offers != null: @@ -5023,12 +5346,25 @@ const ALTERNATIVE_BILLING_MODE_ANDROID_VALUES = { AlternativeBillingModeAndroid.ALTERNATIVE_ONLY: "alternative-only" } +const BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_VALUES = { + BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE: "rectangular-four-by-one", + BillingChoiceImageLayoutAndroid.RECTANGULAR_THREE_BY_ONE: "rectangular-three-by-one", + BillingChoiceImageLayoutAndroid.RECTANGULAR_TWO_BY_TWO: "rectangular-two-by-two" +} + +const BILLING_CHOICE_SCREEN_TYPE_ANDROID_VALUES = { + BillingChoiceScreenTypeAndroid.UNSPECIFIED: "unspecified", + BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED: "developer-rendered", + BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED: "google-rendered" +} + const BILLING_PROGRAM_ANDROID_VALUES = { BillingProgramAndroid.UNSPECIFIED: "unspecified", BillingProgramAndroid.USER_CHOICE_BILLING: "user-choice-billing", BillingProgramAndroid.EXTERNAL_CONTENT_LINK: "external-content-link", BillingProgramAndroid.EXTERNAL_OFFER: "external-offer", - BillingProgramAndroid.EXTERNAL_PAYMENTS: "external-payments" + BillingProgramAndroid.EXTERNAL_PAYMENTS: "external-payments", + BillingProgramAndroid.BILLING_CHOICE: "billing-choice" } const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_VALUES = { @@ -5037,6 +5373,12 @@ const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_VALUES = { DeveloperBillingLaunchModeAndroid.CALLER_WILL_LAUNCH_LINK: "caller-will-launch-link" } +const DEVELOPER_BILLING_TYPE_ANDROID_VALUES = { + DeveloperBillingTypeAndroid.DEVELOPER_BILLING_TYPE_UNSPECIFIED: "developer-billing-type-unspecified", + DeveloperBillingTypeAndroid.IN_APP: "in-app", + DeveloperBillingTypeAndroid.EXTERNAL_LINK: "external-link" +} + const DISCOUNT_OFFER_TYPE_VALUES = { DiscountOfferType.INTRODUCTORY: "introductory", DiscountOfferType.PROMOTIONAL: "promotional", @@ -5145,6 +5487,16 @@ const IAP_STORE_VALUES = { IapStore.AMAZON: "amazon" } +const IN_APP_MESSAGE_CATEGORY_ANDROID_VALUES = { + InAppMessageCategoryAndroid.UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID: "unknown-in-app-message-category-id", + InAppMessageCategoryAndroid.TRANSACTIONAL: "transactional" +} + +const IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_VALUES = { + InAppMessageResponseCodeAndroid.NO_ACTION_NEEDED: "no-action-needed", + InAppMessageResponseCodeAndroid.SUBSCRIPTION_STATUS_UPDATED: "subscription-status-updated" +} + const PAYMENT_MODE_VALUES = { PaymentMode.FREE_TRIAL: "free-trial", PaymentMode.PAY_AS_YOU_GO: "pay-as-you-go", @@ -5299,12 +5651,25 @@ const ALTERNATIVE_BILLING_MODE_ANDROID_FROM_STRING = { "alternative-only": AlternativeBillingModeAndroid.ALTERNATIVE_ONLY } +const BILLING_CHOICE_IMAGE_LAYOUT_ANDROID_FROM_STRING = { + "rectangular-four-by-one": BillingChoiceImageLayoutAndroid.RECTANGULAR_FOUR_BY_ONE, + "rectangular-three-by-one": BillingChoiceImageLayoutAndroid.RECTANGULAR_THREE_BY_ONE, + "rectangular-two-by-two": BillingChoiceImageLayoutAndroid.RECTANGULAR_TWO_BY_TWO +} + +const BILLING_CHOICE_SCREEN_TYPE_ANDROID_FROM_STRING = { + "unspecified": BillingChoiceScreenTypeAndroid.UNSPECIFIED, + "developer-rendered": BillingChoiceScreenTypeAndroid.DEVELOPER_RENDERED, + "google-rendered": BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED +} + const BILLING_PROGRAM_ANDROID_FROM_STRING = { "unspecified": BillingProgramAndroid.UNSPECIFIED, "user-choice-billing": BillingProgramAndroid.USER_CHOICE_BILLING, "external-content-link": BillingProgramAndroid.EXTERNAL_CONTENT_LINK, "external-offer": BillingProgramAndroid.EXTERNAL_OFFER, - "external-payments": BillingProgramAndroid.EXTERNAL_PAYMENTS + "external-payments": BillingProgramAndroid.EXTERNAL_PAYMENTS, + "billing-choice": BillingProgramAndroid.BILLING_CHOICE } const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_FROM_STRING = { @@ -5313,6 +5678,12 @@ const DEVELOPER_BILLING_LAUNCH_MODE_ANDROID_FROM_STRING = { "caller-will-launch-link": DeveloperBillingLaunchModeAndroid.CALLER_WILL_LAUNCH_LINK } +const DEVELOPER_BILLING_TYPE_ANDROID_FROM_STRING = { + "developer-billing-type-unspecified": DeveloperBillingTypeAndroid.DEVELOPER_BILLING_TYPE_UNSPECIFIED, + "in-app": DeveloperBillingTypeAndroid.IN_APP, + "external-link": DeveloperBillingTypeAndroid.EXTERNAL_LINK +} + const DISCOUNT_OFFER_TYPE_FROM_STRING = { "introductory": DiscountOfferType.INTRODUCTORY, "promotional": DiscountOfferType.PROMOTIONAL, @@ -5421,6 +5792,16 @@ const IAP_STORE_FROM_STRING = { "amazon": IapStore.AMAZON } +const IN_APP_MESSAGE_CATEGORY_ANDROID_FROM_STRING = { + "unknown-in-app-message-category-id": InAppMessageCategoryAndroid.UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID, + "transactional": InAppMessageCategoryAndroid.TRANSACTIONAL +} + +const IN_APP_MESSAGE_RESPONSE_CODE_ANDROID_FROM_STRING = { + "no-action-needed": InAppMessageResponseCodeAndroid.NO_ACTION_NEEDED, + "subscription-status-updated": InAppMessageResponseCodeAndroid.SUBSCRIPTION_STATUS_UPDATED +} + const PAYMENT_MODE_FROM_STRING = { "free-trial": PaymentMode.FREE_TRIAL, "pay-as-you-go": PaymentMode.PAY_AS_YOU_GO, @@ -5917,6 +6298,26 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false + ## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + class getBillingChoiceInfoAndroidField: + const name = "getBillingChoiceInfoAndroid" + const snake_name = "get_billing_choice_info_android" + class Args: + var params: GetBillingChoiceInfoParamsAndroid + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("params") and data["params"] != null: + obj.params = data["params"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["params"] = params + return dict + const return_type = "BillingChoiceInfoAndroid" + const is_array = false + # ============================================================================ # Mutation Types @@ -6315,6 +6716,7 @@ class Mutation: const snake_name = "create_billing_program_reporting_details_android" class Args: var program: BillingProgramAndroid + var developer_billing_type: DeveloperBillingTypeAndroid static func from_dict(data: Dictionary) -> Args: var obj = Args.new() @@ -6324,6 +6726,12 @@ class Mutation: obj.program = BILLING_PROGRAM_ANDROID_FROM_STRING[enum_str] else: obj.program = enum_str + if data.has("developerBillingType") and data["developerBillingType"] != null: + var enum_str = data["developerBillingType"] + if enum_str is String and DEVELOPER_BILLING_TYPE_ANDROID_FROM_STRING.has(enum_str): + obj.developer_billing_type = DEVELOPER_BILLING_TYPE_ANDROID_FROM_STRING[enum_str] + else: + obj.developer_billing_type = enum_str return obj func to_dict() -> Dictionary: @@ -6332,11 +6740,15 @@ class Mutation: dict["program"] = BILLING_PROGRAM_ANDROID_VALUES[program] else: dict["program"] = program + if DEVELOPER_BILLING_TYPE_ANDROID_VALUES.has(developer_billing_type): + dict["developerBillingType"] = DEVELOPER_BILLING_TYPE_ANDROID_VALUES[developer_billing_type] + else: + dict["developerBillingType"] = developer_billing_type return dict const return_type = "BillingProgramReportingDetailsAndroid" const is_array = false - ## Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + ## Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), class launchExternalLinkAndroidField: const name = "launchExternalLinkAndroid" const snake_name = "launch_external_link_android" @@ -6356,6 +6768,46 @@ class Mutation: const return_type = "Boolean" const is_array = false + ## Show Google's mandatory information dialog before a developer-rendered, + class showBillingProgramInformationDialogAndroidField: + const name = "showBillingProgramInformationDialogAndroid" + const snake_name = "show_billing_program_information_dialog_android" + class Args: + var params: BillingProgramInformationDialogParamsAndroid + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("params") and data["params"] != null: + obj.params = data["params"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["params"] = params + return dict + const return_type = "BillingResultAndroid" + const is_array = false + + ## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + class showInAppMessagesAndroidField: + const name = "showInAppMessagesAndroid" + const snake_name = "show_in_app_messages_android" + class Args: + var params: InAppMessageParamsAndroid + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("params") and data["params"] != null: + obj.params = data["params"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["params"] = params + return dict + const return_type = "InAppMessageResultAndroid" + const is_array = false + # ============================================================================ # API Wrapper Functions @@ -6484,6 +6936,16 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args +## Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. +static func get_billing_choice_info_android_args(params: GetBillingChoiceInfoParamsAndroid) -> Dictionary: + var args = {} + if params != null: + if params.has_method("to_dict"): + args["params"] = params.to_dict() + else: + args["params"] = params + return args + # Mutation API helpers ## Initialize the store connection. Call before any IAP API. @@ -6638,12 +7100,13 @@ static func is_billing_program_available_android_args(program: BillingProgramAnd return args ## Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). -static func create_billing_program_reporting_details_android_args(program: BillingProgramAndroid) -> Dictionary: +static func create_billing_program_reporting_details_android_args(program: BillingProgramAndroid, developer_billing_type: DeveloperBillingTypeAndroid) -> Dictionary: var args = {} args["program"] = program + args["developerBillingType"] = developer_billing_type return args -## Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). +## Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), static func launch_external_link_android_args(params: LaunchExternalLinkParamsAndroid) -> Dictionary: var args = {} if params != null: @@ -6652,3 +7115,23 @@ static func launch_external_link_android_args(params: LaunchExternalLinkParamsAn else: args["params"] = params return args + +## Show Google's mandatory information dialog before a developer-rendered, +static func show_billing_program_information_dialog_android_args(params: BillingProgramInformationDialogParamsAndroid) -> Dictionary: + var args = {} + if params != null: + if params.has_method("to_dict"): + args["params"] = params.to_dict() + else: + args["params"] = params + return args + +## Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. +static func show_in_app_messages_android_args(params: InAppMessageParamsAndroid) -> Dictionary: + var args = {} + if params != null: + if params.has_method("to_dict"): + args["params"] = params.to_dict() + else: + args["params"] = params + return args diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 6d2d9b825..e36ce758d 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -26,6 +26,7 @@ export interface ActiveSubscription { * pending upgrades/downgrades, and auto-renewal preferences. */ renewalInfoIOS?: (RenewalInfoIOS | null); + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId: string; /** @@ -118,10 +119,35 @@ export interface AppTransaction { } /** - * Billing program types for external content links, external offers, and external payments (Android) - * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 + * Play Billing choice image layout (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ -export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'external-content-link' | 'external-offer' | 'external-payments'; +export type BillingChoiceImageLayoutAndroid = 'rectangular-four-by-one' | 'rectangular-three-by-one' | 'rectangular-two-by-two'; + +/** + * Display information for developer-rendered Billing Choice screens (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface BillingChoiceInfoAndroid { + /** URL for the Play Billing choice image matching the requested layout. */ + playBillingChoiceImageUrl: string; + /** Play Loyalty information for the user. */ + playBillingLoyaltyInfo?: (string | null); +} + +/** + * Choice screen renderer for Billing Choice availability (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export type BillingChoiceScreenTypeAndroid = 'unspecified' | 'developer-rendered' | 'google-rendered'; + +/** + * Billing program types for Google Play Billing Programs (Android) + * Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, + * BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). + */ +export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'external-content-link' | 'external-offer' | 'external-payments' | 'billing-choice'; /** * Result of checking billing program availability (Android) @@ -130,8 +156,30 @@ export type BillingProgramAndroid = 'unspecified' | 'user-choice-billing' | 'ext export interface BillingProgramAvailabilityResultAndroid { /** The billing program that was checked */ billingProgram: BillingProgramAndroid; + /** + * Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + choiceScreenType?: (BillingChoiceScreenTypeAndroid | null); /** Whether the billing program is available for the user */ isAvailable: boolean; + /** + * Whether external-link payment is available for Billing Choice. + * Populated only for available BILLING_CHOICE results. + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + */ + isExternalLinkAvailable?: (boolean | null); +} + +/** + * Parameters for showing a billing program information dialog (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface BillingProgramInformationDialogParamsAndroid { + /** Billing program. Currently only BILLING_CHOICE is supported. */ + billingProgram?: BillingProgramAndroid; + /** External transaction token returned by the Billing Choice reporting-details flow. */ + externalTransactionToken: string; } /** @@ -180,19 +228,38 @@ export interface DeepLinkOptions { export type DeveloperBillingLaunchModeAndroid = 'unspecified' | 'launch-in-external-browser-or-app' | 'caller-will-launch-link'; /** - * Parameters for developer billing option in purchase flow (Android) - * Used with BillingFlowParams to enable external payments flow - * Available in Google Play Billing Library 8.3.0+ + * Parameters for a developer billing option in a purchase flow (Android). + * Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). + * Only billingProgram is required; link fields are used when the selected program + * links outside the app. */ export interface DeveloperBillingOptionParamsAndroid { - /** The billing program (should be EXTERNAL_PAYMENTS for external payments flow) */ + /** The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. */ billingProgram: BillingProgramAndroid; - /** The launch mode for the external payment link */ - launchMode: DeveloperBillingLaunchModeAndroid; - /** The URI where the external payment will be processed */ - linkUri: string; + /** + * A pre-generated external transaction token for a Billing Choice external-link + * flow. Omit it when Google Play should provide the token in the callback. + */ + externalTransactionToken?: (string | null); + /** + * The launch mode for the external payment link. + * Required only when the selected billing program links outside the app. + */ + launchMode?: (DeveloperBillingLaunchModeAndroid | null); + /** + * The URI where the external payment will be processed. + * Required only when the selected billing program links outside the app. + */ + linkUri?: (string | null); } +/** + * Developer-provided billing destination type for Billing Program reporting details (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export type DeveloperBillingTypeAndroid = 'developer-billing-type-unspecified' | 'in-app' | 'external-link'; + /** * Details provided when user selects developer billing option (Android) * Received via DeveloperProvidedBillingListener callback @@ -201,10 +268,31 @@ export interface DeveloperBillingOptionParamsAndroid { export interface DeveloperProvidedBillingDetailsAndroid { /** * External transaction token used to report transactions made through developer billing. - * This token must be used when reporting the external transaction to Google Play. - * Must be reported within 24 hours of the transaction. + * Nullable for flows such as external payments where no token is returned. */ - externalTransactionToken: string; + externalTransactionToken?: (string | null); + /** + * URI to launch for an external-link Billing Choice flow, when provided by + * Google Play. + */ + linkUri?: (string | null); + /** + * Original external transaction ID when replacing a subscription that was + * purchased through developer billing. + */ + originalExternalTransactionId?: (string | null); + /** Products selected for the developer billing flow. */ + products: DeveloperProvidedBillingProductAndroid[]; +} + +/** Product selected for developer-provided billing (Android 9.0+). */ +export interface DeveloperProvidedBillingProductAndroid { + /** Product identifier. */ + id: string; + /** Subscription offer token, when applicable. */ + offerToken?: (string | null); + /** Google Play product type (in-app or subscription). */ + type: ProductType; } /** @@ -507,6 +595,19 @@ export interface ExternalPurchaseNoticeResultIOS { export type FetchProductsResult = ProductOrSubscription[] | Product[] | ProductSubscription[] | null; +/** + * Parameters for fetching Billing Choice display information (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ +export interface GetBillingChoiceInfoParamsAndroid { + /** Billing program. Currently only BILLING_CHOICE is supported. */ + billingProgram?: BillingProgramAndroid; + /** Desired Play Billing choice image layout. */ + playBillingChoiceImageLayout?: BillingChoiceImageLayoutAndroid; + /** BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. */ + userLocale?: (string | null); +} + export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product-ios' | 'user-choice-billing-android' | 'developer-provided-billing-android' | 'subscription-billing-issue'; export type IapPlatform = 'ios' | 'android'; @@ -516,6 +617,42 @@ 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'; +/** + * High-level in-app message category (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export type InAppMessageCategoryAndroid = 'unknown-in-app-message-category-id' | 'transactional'; + +/** + * Parameters for showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export interface InAppMessageParamsAndroid { + /** In-app message categories to show. Defaults to transactional messages. */ + categories?: (InAppMessageCategoryAndroid[] | null); +} + +/** + * Response code from Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export type InAppMessageResponseCodeAndroid = 'no-action-needed' | 'subscription-status-updated'; + +/** + * Result from showing Play billing in-app messages (Android) + * Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + */ +export interface InAppMessageResultAndroid { + /** Purchase token returned when a subscription status changed. */ + purchaseToken?: (string | null); + /** Response code for the in-app messaging flow. */ + responseCode: InAppMessageResponseCodeAndroid; +} + /** Connection initialization configuration */ export interface InitConnectionConfig { /** @@ -525,6 +662,16 @@ export interface InitConnectionConfig { * Use USER_CHOICE_BILLING for user choice billing, EXTERNAL_OFFER for alternative only. */ alternativeBillingModeAndroid?: (AlternativeBillingModeAndroid | null); + /** + * Billing Choice renderer configured in Play Console. Available in OpenIAP + * Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + * can emit the selection event. DEVELOPER_RENDERED omits that listener so the + * app can render its own choice screen and use the reporting/dialog/link APIs. + * Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + * Defaults to GOOGLE_RENDERED. + */ + billingChoiceScreenTypeAndroid?: (BillingChoiceScreenTypeAndroid | null); /** * Enable a specific billing program for Android (7.0+) * When set, enables the specified billing program for external transactions. @@ -532,6 +679,8 @@ export interface InitConnectionConfig { * - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) * - EXTERNAL_OFFER: External offers for digital content (8.2.0+) * - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + * - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + * (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) */ enableBillingProgramAndroid?: (BillingProgramAndroid | null); } @@ -559,12 +708,19 @@ export interface InstallmentPlanDetailsAndroid { /** * Parameters for launching an external link (Android) - * Used with launchExternalLink to initiate external offer or app install flows + * Used with launchExternalLink to initiate external offer, app install, or + * developer-rendered Billing Choice flows * Available in Google Play Billing Library 8.2.0+ */ export interface LaunchExternalLinkParamsAndroid { - /** The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) */ + /** The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) */ billingProgram: BillingProgramAndroid; + /** + * External transaction token for a developer-rendered Billing Choice external-link + * flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + */ + externalTransactionToken?: (string | null); /** The external link launch mode */ launchMode: ExternalLinkLaunchModeAndroid; /** The type of the external link */ @@ -597,7 +753,6 @@ export interface Mutation { beginRefundRequestIOS?: Promise<(string | null)>; /** * Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. - * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android @@ -617,7 +772,6 @@ export interface Mutation { * Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. * Must be called AFTER successful payment in your payment system. * Token must be reported to Google Play backend within 24 hours. - * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android @@ -626,8 +780,11 @@ export interface Mutation { /** * Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). * Replaces the deprecated createExternalOfferReportingDetailsAsync API. - * * Returns external transaction token needed for reporting external transactions. + * developerBillingType is optional. When program is BILLING_CHOICE and developerBillingType is omitted, + * native Android defaults it to IN_APP. + * The Billing Choice extension is available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ @@ -655,7 +812,6 @@ export interface Mutation { /** * Check whether a billing program (e.g., External Payments) is available for the current user. * Replaces the deprecated isExternalOfferAvailableAsync API. - * * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. @@ -663,9 +819,11 @@ export interface Mutation { */ isBillingProgramAvailableAndroid: Promise; /** - * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). + * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+), + * including developer-rendered Billing Choice external-link flows. + * Billing Choice availability: OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). * Replaces the deprecated showExternalOfferInformationDialog API. - * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/launch-external-link-android @@ -711,12 +869,19 @@ export interface Mutation { /** * Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. * Must be called BEFORE processing payment in your payment system. - * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ showAlternativeBillingDialogAndroid: Promise; + /** + * Show Google's mandatory information dialog before a developer-rendered, + * in-app Billing Choice screen. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-billing-program-information-dialog-android + */ + showBillingProgramInformationDialogAndroid: Promise; /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. @@ -724,6 +889,15 @@ export interface Mutation { * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ showExternalPurchaseCustomLinkNoticeIOS: Promise; + /** + * Overlay Play billing in-app messages, such as payment issues or subscription price-change confirmations. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 + * (upstream API available since Play Billing 4.1.0). + * Returns a response code and, when the subscription status changes, the related purchase token. + * Throws OpenIapError.NotPrepared if billing client not ready. + * See: https://openiap.dev/docs/apis/android/show-in-app-messages-android + */ + showInAppMessagesAndroid: Promise; /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios @@ -766,7 +940,11 @@ export type MutationBeginRefundRequestIosArgs = string; export type MutationConsumePurchaseAndroidArgs = string; -export type MutationCreateBillingProgramReportingDetailsAndroidArgs = BillingProgramAndroid; +export interface MutationCreateBillingProgramReportingDetailsAndroidArgs { + developerBillingType?: (DeveloperBillingTypeAndroid | null); + program: BillingProgramAndroid; +} + export type MutationDeepLinkToSubscriptionsArgs = (DeepLinkOptions | null) | undefined; @@ -801,8 +979,12 @@ export type MutationRequestPurchaseArgs = }; +export type MutationShowBillingProgramInformationDialogAndroidArgs = BillingProgramInformationDialogParamsAndroid; + export type MutationShowExternalPurchaseCustomLinkNoticeIosArgs = ExternalPurchaseCustomLinkNoticeTypeIOS; +export type MutationShowInAppMessagesAndroidArgs = (InAppMessageParamsAndroid | null) | undefined; + export type MutationValidateReceiptArgs = VerifyPurchaseProps; export type MutationVerifyPurchaseArgs = VerifyPurchaseProps; @@ -1197,6 +1379,7 @@ export interface PurchaseAndroid extends PurchaseCommon { signatureAndroid?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId?: (string | null); } @@ -1221,6 +1404,7 @@ export interface PurchaseCommon { quantity: number; /** Store where purchase was made */ store: IapStore; + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; } @@ -1278,6 +1462,7 @@ export interface PurchaseIOS extends PurchaseCommon { store: IapStore; storefrontCountryCodeIOS?: (string | null); subscriptionGroupIdIOS?: (string | null); + /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId: string; transactionReasonIOS?: (string | null); @@ -1359,6 +1544,13 @@ export interface Query { * See: https://openiap.dev/docs/apis/get-available-purchases */ getAvailablePurchases: Promise; + /** + * Fetch Play Billing assets and loyalty text for developer-rendered Billing Choice screens. + * OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + * Throws OpenIapError.NotPrepared if billing client is not ready. + * See: https://openiap.dev/docs/apis/android/get-billing-choice-info-android + */ + getBillingChoiceInfoAndroid: Promise; /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. @@ -1447,6 +1639,8 @@ export type QueryGetActiveSubscriptionsArgs = (string[] | null) | undefined; export type QueryGetAvailablePurchasesArgs = (PurchaseOptions | null) | undefined; +export type QueryGetBillingChoiceInfoAndroidArgs = GetBillingChoiceInfoParamsAndroid; + export type QueryGetExternalPurchaseCustomLinkTokenIosArgs = ExternalPurchaseCustomLinkTokenTypeIOS; export type QueryGetTransactionJwsIosArgs = string; @@ -1546,9 +1740,9 @@ export interface RentalDetailsAndroid { export interface RequestPurchaseAndroidProps { /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ developerBillingOption?: (DeveloperBillingOptionParamsAndroid | null); /** @@ -1633,9 +1827,9 @@ export type RequestPurchaseResult = Purchase | Purchase[] | null; export interface RequestSubscriptionAndroidProps { /** - * Developer billing option parameters for external payments flow (8.3.0+). - * When provided, the purchase flow will show a side-by-side choice between - * Google Play Billing and the developer's external payment option. + * Developer billing option parameters for external payments and Billing Choice. + * Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + * (requires Play Billing 9.1.0+). */ developerBillingOption?: (DeveloperBillingOptionParamsAndroid | null); /** @@ -1647,6 +1841,12 @@ export interface RequestSubscriptionAndroidProps { obfuscatedAccountId?: (string | null); /** Obfuscated profile ID */ obfuscatedProfileId?: (string | null); + /** + * Original external transaction ID for replacing a subscription that was + * purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). + */ + originalExternalTransactionId?: (string | null); /** Purchase token for upgrades/downgrades */ purchaseToken?: (string | null); /** @@ -1782,11 +1982,11 @@ export type SubResponseCodeAndroid = 'no-applicable-sub-response-code' | 'paymen export interface Subscription { /** - * Fires when a user selects developer billing in the External Payments flow (Android only) - * Triggered when the user chooses to pay via the developer's external payment option - * instead of Google Play Billing in the side-by-side choice dialog. - * Contains the externalTransactionToken needed to report the transaction. - * Available in Google Play Billing Library 8.3.0+ + * Fires when a user selects developer billing in an External Payments or + * Billing Choice flow (Android only). The payload can contain an external + * transaction token, link URI, original transaction ID, and selected products. + * Billing Choice payload fields are available in OpenIAP Spec 2.1.0 / + * openiap-google 2.3.0 (requires Play Billing 9.1.0+). */ developerProvidedBillingAndroid: DeveloperProvidedBillingDetailsAndroid; /** Fires when the App Store surfaces a promoted product (iOS only) */ @@ -2235,6 +2435,7 @@ export type QueryArgsMap = { getAllTransactionsIOS: never; getAppTransactionIOS: never; getAvailablePurchases: QueryGetAvailablePurchasesArgs; + getBillingChoiceInfoAndroid: QueryGetBillingChoiceInfoAndroidArgs; getExternalPurchaseCustomLinkTokenIOS: QueryGetExternalPurchaseCustomLinkTokenIosArgs; getPendingTransactionsIOS: never; getPromotedProductIOS: never; @@ -2285,7 +2486,9 @@ export type MutationArgsMap = { requestPurchaseOnPromotedProductIOS: never; restorePurchases: never; showAlternativeBillingDialogAndroid: never; + showBillingProgramInformationDialogAndroid: MutationShowBillingProgramInformationDialogAndroidArgs; showExternalPurchaseCustomLinkNoticeIOS: MutationShowExternalPurchaseCustomLinkNoticeIosArgs; + showInAppMessagesAndroid: MutationShowInAppMessagesAndroidArgs; showManageSubscriptionsIOS: never; syncIOS: never; validateReceipt: MutationValidateReceiptArgs; diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql index 339a835fc..a27655e79 100644 --- a/packages/gql/src/type-android.graphql +++ b/packages/gql/src/type-android.graphql @@ -348,6 +348,9 @@ type PurchaseAndroid implements PurchaseCommon { productId: String! ids: [String!] transactionId: String # Optional in Android unlike iOS + """ + Unix timestamp in milliseconds since January 1, 1970 UTC. + """ transactionDate: Float! purchaseToken: String """ @@ -432,9 +435,9 @@ input RequestPurchaseAndroidProps { """ offerToken: String """ - Developer billing option parameters for external payments flow (8.3.0+). - When provided, the purchase flow will show a side-by-side choice between - Google Play Billing and the developer's external payment option. + Developer billing option parameters for external payments and Billing Choice. + Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + (requires Play Billing 9.1.0+). """ developerBillingOption: DeveloperBillingOptionParamsAndroid } @@ -462,6 +465,12 @@ input RequestSubscriptionAndroidProps { """ purchaseToken: String """ + Original external transaction ID for replacing a subscription that was + purchased through developer billing. Available in OpenIAP Spec 2.1.0 / + openiap-google 2.3.0 (requires Play Billing 9.1.0+). + """ + originalExternalTransactionId: String + """ Replacement mode for subscription changes @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) """ @@ -476,9 +485,9 @@ input RequestSubscriptionAndroidProps { """ subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid """ - Developer billing option parameters for external payments flow (8.3.0+). - When provided, the purchase flow will show a side-by-side choice between - Google Play Billing and the developer's external payment option. + Developer billing option parameters for external payments and Billing Choice. + Billing Choice is available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + (requires Play Billing 9.1.0+). """ developerBillingOption: DeveloperBillingOptionParamsAndroid } @@ -683,8 +692,10 @@ type UserChoiceBillingDetails { # External Billing Programs (Google Play Billing Library 8.2.0+) """ -Billing program types for external content links, external offers, and external payments (Android) -Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0 +Billing program types for Google Play Billing Programs (Android) +Available in Google Play Billing Library 8.2.0+, EXTERNAL_PAYMENTS added in 8.3.0, +BILLING_CHOICE added in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +(requires Play Billing 9.1.0+). """ enum BillingProgramAndroid { """ @@ -716,6 +727,103 @@ enum BillingProgramAndroid { Available in Google Play Billing Library 8.3.0+ """ EXTERNAL_PAYMENTS + """ + Billing Choice program. + Allows presenting Google Play Billing alongside an alternative in-app billing system or external web link. + Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + """ + BILLING_CHOICE +} + +""" +Choice screen renderer for Billing Choice availability (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +""" +enum BillingChoiceScreenTypeAndroid { + """ + Unspecified choice screen type. + """ + UNSPECIFIED + """ + Choice screen is rendered by the developer app. + """ + DEVELOPER_RENDERED + """ + Choice screen is rendered by Google Play. + """ + GOOGLE_RENDERED +} + +""" +Play Billing choice image layout (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +""" +enum BillingChoiceImageLayoutAndroid { + """ + Rectangular image with a 4:1 aspect ratio. + """ + RECTANGULAR_FOUR_BY_ONE + """ + Rectangular image with a 3:1 aspect ratio. + """ + RECTANGULAR_THREE_BY_ONE + """ + Rectangular image with a 2:2 aspect ratio. + """ + RECTANGULAR_TWO_BY_TWO +} + +""" +Parameters for fetching Billing Choice display information (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +""" +input GetBillingChoiceInfoParamsAndroid { + """ + Billing program. Currently only BILLING_CHOICE is supported. + """ + billingProgram: BillingProgramAndroid! = BILLING_CHOICE + """ + Desired Play Billing choice image layout. + """ + playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid! = RECTANGULAR_FOUR_BY_ONE + """ + BCP 47 locale tag. If omitted, Play Billing uses the user's default locale. + """ + userLocale: String +} + +""" +Developer-provided billing destination type for Billing Program reporting details (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +""" +enum DeveloperBillingTypeAndroid { + """ + Unspecified developer billing type. Do not use. + """ + DEVELOPER_BILLING_TYPE_UNSPECIFIED + """ + Developer-provided billing via native in-app experience. + """ + IN_APP + """ + Developer-provided billing via external link or embedded web browsing. + """ + EXTERNAL_LINK +} + +""" +Display information for developer-rendered Billing Choice screens (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +""" +type BillingChoiceInfoAndroid { + """ + URL for the Play Billing choice image matching the requested layout. + """ + playBillingChoiceImageUrl: String! + """ + Play Loyalty information for the user. + """ + playBillingLoyaltyInfo: String } """ @@ -760,12 +868,13 @@ enum ExternalLinkTypeAndroid { """ Parameters for launching an external link (Android) -Used with launchExternalLink to initiate external offer or app install flows +Used with launchExternalLink to initiate external offer, app install, or +developer-rendered Billing Choice flows Available in Google Play Billing Library 8.2.0+ """ input LaunchExternalLinkParamsAndroid { """ - The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + The billing program (EXTERNAL_CONTENT_LINK, EXTERNAL_OFFER, or BILLING_CHOICE) """ billingProgram: BillingProgramAndroid! """ @@ -780,6 +889,12 @@ input LaunchExternalLinkParamsAndroid { The URI where the content will be accessed from """ linkUri: String! + """ + External transaction token for a developer-rendered Billing Choice external-link + flow. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 + (requires Play Billing 9.1.0+). Generate it with createBillingProgramReportingDetailsAndroid. + """ + externalTransactionToken: String } """ @@ -812,6 +927,92 @@ type BillingProgramAvailabilityResultAndroid { The billing program that was checked """ billingProgram: BillingProgramAndroid! + """ + Billing Choice screen renderer. Populated only for available BILLING_CHOICE results. + Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + """ + choiceScreenType: BillingChoiceScreenTypeAndroid + """ + Whether external-link payment is available for Billing Choice. + Populated only for available BILLING_CHOICE results. + Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0. + """ + isExternalLinkAvailable: Boolean +} + +""" +Parameters for showing a billing program information dialog (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). +""" +input BillingProgramInformationDialogParamsAndroid { + """ + Billing program. Currently only BILLING_CHOICE is supported. + """ + billingProgram: BillingProgramAndroid! = BILLING_CHOICE + """ + External transaction token returned by the Billing Choice reporting-details flow. + """ + externalTransactionToken: String! +} + +""" +High-level in-app message category (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +(upstream API available since Play Billing 4.1.0). +""" +enum InAppMessageCategoryAndroid { + """ + Unknown in-app message category. + """ + UNKNOWN_IN_APP_MESSAGE_CATEGORY_ID + """ + Transactional billing messages, such as payment issues or pending price-change confirmations. + """ + TRANSACTIONAL +} + +""" +Parameters for showing Play billing in-app messages (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +(upstream API available since Play Billing 4.1.0). +""" +input InAppMessageParamsAndroid { + """ + In-app message categories to show. Defaults to transactional messages. + """ + categories: [InAppMessageCategoryAndroid!] = [TRANSACTIONAL] +} + +""" +Result from showing Play billing in-app messages (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +(upstream API available since Play Billing 4.1.0). +""" +type InAppMessageResultAndroid { + """ + Response code for the in-app messaging flow. + """ + responseCode: InAppMessageResponseCodeAndroid! + """ + Purchase token returned when a subscription status changed. + """ + purchaseToken: String +} + +""" +Response code from Play billing in-app messages (Android) +Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 +(upstream API available since Play Billing 4.1.0). +""" +enum InAppMessageResponseCodeAndroid { + """ + Flow finished and no developer action is needed. + """ + NO_ACTION_NEEDED + """ + Subscription status changed and the purchase token should be checked. + """ + SUBSCRIPTION_STATUS_UPDATED } # External Payments Program (Google Play Billing Library 8.3.0+) @@ -840,23 +1041,32 @@ enum DeveloperBillingLaunchModeAndroid { } """ -Parameters for developer billing option in purchase flow (Android) -Used with BillingFlowParams to enable external payments flow -Available in Google Play Billing Library 8.3.0+ +Parameters for a developer billing option in a purchase flow (Android). +Used with BillingFlowParams for external payments (8.3.0+) and Billing Choice +(OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+). +Only billingProgram is required; link fields are used when the selected program +links outside the app. """ input DeveloperBillingOptionParamsAndroid { """ - The billing program (should be EXTERNAL_PAYMENTS for external payments flow) + The billing program. Use EXTERNAL_PAYMENTS or BILLING_CHOICE. """ billingProgram: BillingProgramAndroid! """ - The URI where the external payment will be processed + The URI where the external payment will be processed. + Required only when the selected billing program links outside the app. """ - linkUri: String! + linkUri: String + """ + The launch mode for the external payment link. + Required only when the selected billing program links outside the app. + """ + launchMode: DeveloperBillingLaunchModeAndroid """ - The launch mode for the external payment link + A pre-generated external transaction token for a Billing Choice external-link + flow. Omit it when Google Play should provide the token in the callback. """ - launchMode: DeveloperBillingLaunchModeAndroid! + externalTransactionToken: String } """ @@ -867,10 +1077,41 @@ Available in Google Play Billing Library 8.3.0+ type DeveloperProvidedBillingDetailsAndroid { """ External transaction token used to report transactions made through developer billing. - This token must be used when reporting the external transaction to Google Play. - Must be reported within 24 hours of the transaction. + Nullable for flows such as external payments where no token is returned. """ - externalTransactionToken: String! + externalTransactionToken: String + """ + URI to launch for an external-link Billing Choice flow, when provided by + Google Play. + """ + linkUri: String + """ + Original external transaction ID when replacing a subscription that was + purchased through developer billing. + """ + originalExternalTransactionId: String + """ + Products selected for the developer billing flow. + """ + products: [DeveloperProvidedBillingProductAndroid!]! +} + +""" +Product selected for developer-provided billing (Android 9.0+). +""" +type DeveloperProvidedBillingProductAndroid { + """ + Product identifier. + """ + id: String! + """ + Google Play product type (in-app or subscription). + """ + type: ProductType! + """ + Subscription offer token, when applicable. + """ + offerToken: String } # Deprecated External Offers APIs (Google Play Billing Library 8.2.0) diff --git a/packages/gql/src/type-ios.graphql b/packages/gql/src/type-ios.graphql index 9b9093899..1404ffbdb 100644 --- a/packages/gql/src/type-ios.graphql +++ b/packages/gql/src/type-ios.graphql @@ -224,6 +224,7 @@ type PurchaseIOS implements PurchaseCommon { id: ID! productId: String! ids: [String!] + """Unix timestamp in milliseconds since January 1, 1970 UTC.""" transactionDate: Float! purchaseToken: String """ diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index e7f9cb59b..eed6aefd9 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -26,8 +26,9 @@ enum IapEvent { PromotedProductIOS UserChoiceBillingAndroid """ - Fired when user selects developer-provided billing option in external payments flow. - Available on Android with Google Play Billing Library 8.3.0+ + Fired for External Payments (8.3.0+) and Google-rendered Billing Choice + developer billing selections on Android. Billing Choice is available in + OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). """ DeveloperProvidedBillingAndroid """ @@ -71,6 +72,9 @@ interface PurchaseCommon { id: ID! productId: String! ids: [String!] + """ + Unix timestamp in milliseconds since January 1, 1970 UTC. + """ transactionDate: Float! """ Unified purchase token (iOS JWS, Android purchaseToken) @@ -188,6 +192,9 @@ input PurchaseInput { id: ID! productId: String! ids: [String!] + """ + Unix timestamp in milliseconds since January 1, 1970 UTC. + """ transactionDate: Float! purchaseToken: String """ @@ -446,6 +453,9 @@ type ActiveSubscription { daysUntilExpirationIOS: Float transactionId: String! purchaseToken: String + """ + Unix timestamp in milliseconds since January 1, 1970 UTC. + """ transactionDate: Float! basePlanIdAndroid: String """ @@ -793,8 +803,20 @@ input InitConnectionConfig { - EXTERNAL_CONTENT_LINK: Link to external content (8.2.0+) - EXTERNAL_OFFER: External offers for digital content (8.2.0+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) + - BILLING_CHOICE: Google-rendered or developer-rendered billing choice + (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) """ enableBillingProgramAndroid: BillingProgramAndroid + """ + Billing Choice renderer configured in Play Console. Available in OpenIAP + Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). + GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP + can emit the selection event. DEVELOPER_RENDERED omits that listener so the + app can render its own choice screen and use the reporting/dialog/link APIs. + Must match choiceScreenType returned by isBillingProgramAvailableAndroid. + Defaults to GOOGLE_RENDERED. + """ + billingChoiceScreenTypeAndroid: BillingChoiceScreenTypeAndroid = GOOGLE_RENDERED # Note: enableAutoServiceReconnection is always enabled internally (Billing 8.0+) - # since OpenIAP uses Billing Library 8.3.0+ + # OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+) } diff --git a/packages/kit/src/content/faq.md b/packages/kit/src/content/faq.md index d2d384f08..1b5295c78 100644 --- a/packages/kit/src/content/faq.md +++ b/packages/kit/src/content/faq.md @@ -2,7 +2,7 @@ ## When should I perform receipt validation? -Run validation immediately after your app receives a purchase receipt and again at any time the purchase state matters—unlocking premium content, restoring purchases on a new device, or reconciling renewals. IAPKit is designed to handle both real-time checks and scheduled re-validations so you can catch subscriptions that lapse, refunds, or revoked purchases without waiting for user reports. +Run validation immediately after your app receives a purchase receipt and again at any time the purchase state matters - unlocking premium content, restoring purchases on a new device, or reconciling renewals. A Purchases row records the store state observed by its latest verification; it is not a live subscription watcher. Use subscription webhooks and entitlement endpoints for lifecycle state, and schedule revalidation when a current purchase snapshot is required. On Android, verify the purchase, finish it, then verify the token again when the Purchases log must reflect the completed acknowledgment. ## Why should I perform receipt validation? diff --git a/packages/kit/src/pages/auth/organization/project/PurchasesTable.tsx b/packages/kit/src/pages/auth/organization/project/PurchasesTable.tsx index 0a279539d..bc56c5385 100644 --- a/packages/kit/src/pages/auth/organization/project/PurchasesTable.tsx +++ b/packages/kit/src/pages/auth/organization/project/PurchasesTable.tsx @@ -127,8 +127,8 @@ export function PurchasesTable({
    NameTypeSummary
    + responseCode + + + InAppMessageResponseCodeAndroid + + Flow result.
    + purchaseToken + + string | null + + Purchase token returned when a subscription status changed.
    - Developer billing option params for the External - Payments flow (8.3.0+). See{' '} + Developer billing option params for External Payments or + Billing Choice. Billing Choice is available in OpenIAP + Spec 2.1.0 / openiap-google 2.3.0 and requires Play + Billing 9.1.0+. In-app flows require only{' '} + billingProgram. See{' '} DeveloperBillingOptionParamsAndroid @@ -624,6 +627,16 @@ await iap.request_purchase(subs_props)`} Existing subscription token for upgrade/downgrade
    + originalExternalTransactionId + + Original transaction ID when replacing a subscription + purchased through developer billing (9.1.0+). Use this + instead of purchaseToken for that flow. +
    @@ -659,8 +672,8 @@ await iap.request_purchase(subs_props)`} developerBillingOption - Developer billing option params (External Payments, - 8.3.0+). See{' '} + Developer billing option params for External Payments + (8.3.0+) or Billing Choice (9.1.0+). See{' '} DeveloperBillingOptionParamsAndroid diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 6c51e6e6a..5702ca362 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,20 +26,21 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ - // July 3, 2026 — Amazon config plugin option shape + // July 11, 2026 - Multi-store runtimes, Play Billing 9.1, and SDK parity { - id: 'amazon-config-plugin-option-shape-2026-07-03', - date: new Date('2026-07-03'), + id: 'multi-store-billing-9-1-sdk-release-2026-07-11', + date: new Date('2026-07-11'), element: (
    - July 3, 2026 — Amazon config plugin option shape + July 11, 2026 - Multi-store runtimes, Play Billing 9.1, and SDK + parity

    - Publishes Expo prereleases that group optional store modules under{' '} - modules. Expo apps should configure{' '} - modules.amazon.fireOS and{' '} - modules.amazon.vegaOS; android.amazon{' '} - remains for Android-specific details such as optional Vega metadata - overrides. React Native remains a bare RN/Nitro package that selects - Fire OS through Android Gradle and Vega OS through a separate React - Native for Vega target. Amazon targets are available in the current{' '} - next / rc package versions while this - support remains experimental. + Publishes the current multi-package release train with OpenIAP Spec + 2.1.0, Google Play Billing Library 9.1.0, Amazon Fire OS and Vega OS + integrations, IAPKit lifecycle fixes, and product metadata parity + across the framework SDKs. Fire OS and Vega OS remain experimental; + Apple adds compatibility fixes without changing its public purchase + API.

    - {`plugins: [ - [ - 'expo-iap', - { - modules: { - onside: true, - horizon: true, - amazon: { - fireOS: true, - vegaOS: true, - }, - }, - android: { - horizon: { - appId: 'YOUR_HORIZON_APP_ID', - }, - }, - }, - ], -]`} +
    + Platform packages and IAPKit +
    +
      +
    • + OpenIAP Spec 2.1.0 - adds Amazon as a store and + IAPKit verification target, then adds Billing Choice, developer + billing, dialog, reporting, and in-app message contracts shared by + every generated SDK. +
    • +
    • + openiap-google 2.3.0 - ships Play Billing 9.1.0 + with BILLING_CHOICE, renderer selection, Billing + Choice metadata and information dialog APIs, developer billing + reporting, and preserved subResponseCode errors.{' '} + + showInAppMessagesAndroid + {' '} + covers transactional and price-increase messages requested in{' '} + + issue #221 + + . +
    • +
    • + Android store runtimes - the same native package + family now targets Play, Meta Horizon, or Amazon Appstore by + flavor. Horizon uses Billing Compatibility SDK 2.0.0 and the + canonical com.meta.horizon.platform.HORIZON_APP_ID{' '} + key; historical keys remain readable for migration. Amazon + cancellation is delivered once as user-cancelled, and + Android callback continuations ignore duplicate completion + attempts. +
    • +
    • + openiap-apple 2.2.5 - keeps newer StoreKit paths + available to Swift 5 language-mode apps when the compiler supports + them, aligns Xcode 26 signatures, and preserves pricing terms, + subscription details, offers, and debug metadata across bridges. +
    • +
    • + IAPKit - adds Amazon receipt validation and + end-to-end App Store Connect and Google Play catalog sync. Direct + Apple/Google verification now creates bindable subscription rows + for{' '} + + issue #209 + + ; iOS subscription responses expose stable{' '} + originalTransactionId identity from{' '} + + PR #214 + + . Purchase rows are documented as verification snapshots, while + subscription endpoints and webhooks remain the lifecycle source. +
    • +
    +
    Framework libraries
    • - Expo config plugin — expo-iap now - resolves Fire OS and Vega OS from modules.amazon, - resolves Horizon app ids from android.horizon.appId, - and derives Vega metadata from the Expo config unless{' '} - android.amazon.vegaOS overrides are provided. + react-native-iap 15.4.0 - adds Fire OS and React + Native for Vega adapters, the complete Billing Choice and in-app + message surface, bounded Vega fulfillment timeouts, and Nitro + product metadata preservation.
    • - React Native — react-native-iap{' '} - remains a bare React Native/Nitro package. Fire OS uses direct - Android Gradle flavor selection, and Vega uses a separate React - Native for Vega target with its own Kepler metadata. + expo-iap 4.4.0 - adds Fire OS and Vega modules, + the Billing 9.1 APIs, and config-plugin setup under{' '} + modules.amazon. The plugin writes canonical Horizon + metadata and migrates earlier app-id keys.
    • - Vega OS scope — Vega remains experimental and - limited to React Native for Vega / compatible Expo Vega targets. - Flutter, KMP, MAUI, and Godot do not expose a Kepler runtime - target. + flutter_inapp_purchase 9.4.0 - adds Play, + Horizon, and Amazon Android builds plus the Billing 9.1 APIs, and + preserves standardized offers, pricing terms, discounts, + installment details, and per-product status during channel + decoding.
    • - Fire OS scope — Fire OS remains the Android{' '} - amazon flavor across native Android, Expo, React - Native, Flutter, KMP, and MAUI. Godot currently exposes shared - Amazon API payloads but no separate Fire OS flavor switch. + godot-iap 2.4.0 - adds the Play Billing Choice + APIs, reliable boolean launch results, and typed nested metadata + decoding. Amazon verification types are shared, but Godot does not + add a separate Fire OS runtime target in this release. +
    • +
    • + kmp-iap 2.4.0 - adds Play, Horizon, and Amazon + targets and the Billing 9.1 APIs, while preserving requested order + and rich metadata for mixed type: all product + queries. +
    • +
    • + OpenIap.Maui 1.2.1 - adds Play, Horizon, and + Amazon Android packaging plus the Billing 9.1 APIs. Horizon uses + the 2.0.0 compatibility runtime without pulling Google Billing + classes into the Horizon artifact. +
    • +
    + +
    + Documentation and release quality +
    +
      +
    • + Store setup and example coverage now includes Fire OS, Vega OS, + Horizon, Play, and Apple flows; duplicate example transaction + cleanup is suppressed after successful processing. CI also builds + store-specific KMP and MAUI variants and retries timing-only web + performance flakes without relaxing byte-size budgets. +
    • +
    • + Release history remains centralized here, with package-specific + sections for the changelog request in{' '} + + issue #206 + + . Package-local changelogs continue to point to this page and + package GitHub Releases.
    @@ -131,15 +232,42 @@ function Releases() { >
  • - react-native-iap 15.4.0-rc.2 + openiap-apple 2.2.5 + +
  • +
  • + + openiap-google 2.3.0 {' '} ( + Maven Central + + ) +
  • +
  • + + react-native-iap 15.4.0 + {' '} + ( + @@ -149,15 +277,15 @@ function Releases() {
  • - expo-iap 4.4.0-rc.6 + expo-iap 4.4.0 {' '} ( @@ -165,6 +293,69 @@ function Releases() { )
  • +
  • + + flutter_inapp_purchase 9.4.0 + {' '} + ( + + pub.dev + + ) +
  • +
  • + + godot-iap 2.4.0 + +
  • +
  • + + kmp-iap 2.4.0 + {' '} + ( + + Maven Central + + ) +
  • +
  • + + OpenIap.Maui 1.2.1 + {' '} + ( + + NuGet + + ) +
  • @@ -1516,171 +1707,6 @@ 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-rc.1, Android builds can target - Google Play, Meta Horizon, or Amazon Appstore from the same native - package family. The{' '} - OpenIAP Spec remains stable at 2.0.3; this rollout - adds the Fire OS runtime flavor, framework build flags, Amazon - Appstore receipt verification paths, and shared subscription state - mapping without publishing a spec prerelease. -

    - -
      -
    • - 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. -
    • -
    - -
    -
    - Spec and 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/pages/home.tsx b/packages/docs/src/pages/home.tsx index dc5d092d9..8617ebbba 100644 --- a/packages/docs/src/pages/home.tsx +++ b/packages/docs/src/pages/home.tsx @@ -284,7 +284,7 @@ function Home() {
    100%

    Platform Features

    - Full access to StoreKit 2 and Play Billing v8 — no features + Full access to StoreKit 2 and Play Billing v9.1 — no features hidden or abstracted away

    @@ -327,7 +327,7 @@ function Home() {

    Future-Proof

    - As StoreKit 2, Android Billing v8, and new platforms emerge, + As StoreKit 2, Android Billing v9, and new platforms emerge, OpenIAP abstracts these changes behind a stable API.

    diff --git a/packages/docs/src/pages/introduction.tsx b/packages/docs/src/pages/introduction.tsx index 217211f1e..0e4605321 100644 --- a/packages/docs/src/pages/introduction.tsx +++ b/packages/docs/src/pages/introduction.tsx @@ -224,7 +224,7 @@ libraries/maui-iap/src/OpenIap.Maui/Types.cs # C# / MAUI types`} target="_blank" rel="noopener noreferrer" > - Play Billing v8 + Play Billing v9.1 . Supports Android 5.0+ (API 21+).

    @@ -632,7 +632,7 @@ await iap.end_connection()`} target="_blank" rel="noopener noreferrer" > - Play Billing v8 + Play Billing v9.1
    API 21+ (5.0)
    - - + + 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 dc1284508..5e63cc48d 100644 --- a/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx +++ b/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx @@ -272,7 +272,9 @@ export default function PurchaseDetail() {

    {"Receipt details"}

    - {"Inspect every data point we captured for this validated purchase."} + { + "Inspect the latest store verification snapshot. Subscription lifecycle state is tracked separately." + }

    @@ -280,7 +282,7 @@ export default function PurchaseDetail() {

    - {"Status"} + {"Validity"}

    -

    {"State"}

    +

    + {"Verified state"} +

    {(() => { const { label, variant } = getPurchaseStateDisplay( diff --git a/packages/kit/src/pages/auth/organization/project/purchases.tsx b/packages/kit/src/pages/auth/organization/project/purchases.tsx index 274cc6ee3..6ed9e30df 100644 --- a/packages/kit/src/pages/auth/organization/project/purchases.tsx +++ b/packages/kit/src/pages/auth/organization/project/purchases.tsx @@ -311,7 +311,9 @@ export default function ProjectPurchases() {

    {"Purchases"}

    - {"View all verified purchase records."} + { + "View store states captured by each purchase's latest verification. Use Subscriptions for live lifecycle state." + }

    diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx index e1bf8fcb5..a2fc2e5a0 100644 --- a/packages/kit/src/pages/docs/sections/api.tsx +++ b/packages/kit/src/pages/docs/sections/api.tsx @@ -125,11 +125,23 @@ export default function ApiReferencePage() {

    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, + carries the harmonized store state observed by this verification call, and productId is the product id verified by the upstream store. For Meta Horizon, productId is the SKU IAPKit checked.

    + +

    + Calling finishTransaction updates the app and store; it + does not mutate an earlier IAPKit response. On Android, verify first, + finish the valid purchase, then verify the token again if the + Purchases log must reflect ENTITLED instead of{" "} + PENDING_ACKNOWLEDGMENT. Use subscription status, + entitlements, and store webhooks for current subscription lifecycle + state. Periodically reverify purchases when their latest store state + matters. +

    +

    If your own backend keeps an entitlement ledger, do not trust a client-provided product id. Send expectedProductId with the diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 20d26feef..c1488e677 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -1,96 +1,99 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const failures = []; -const EXPO_EXAMPLE_ROOT = 'libraries/expo-iap/example'; +const EXPO_EXAMPLE_ROOT = "libraries/expo-iap/example"; const parityCoveredLibraries = new Set([ - 'expo-iap', - 'flutter_inapp_purchase', - 'kmp-iap', - 'maui-iap', - 'react-native-iap', + "expo-iap", + "flutter_inapp_purchase", + "kmp-iap", + "maui-iap", + "react-native-iap", ]); const parityExcludedLibraries = new Set([ // Godot is intentionally excluded until its example parity is brought back // into the same automated build/test lane as the other SDK examples. - 'godot-iap', + "godot-iap", ]); const featureSpecs = { - 'all-products': {}, - 'purchase-flow': {}, - 'subscription-flow': {}, - 'available-purchases': {}, - 'offer-code': {}, - 'alternative-billing': {}, - 'webhook-stream': {}, + "all-products": {}, + "purchase-flow": {}, + "subscription-flow": {}, + "available-purchases": {}, + "offer-code": {}, + "alternative-billing": {}, + "webhook-stream": {}, }; const operationParityRegistry = { Mutation: [ - 'acknowledgePurchaseAndroid', - 'beginRefundRequestIOS', - 'checkAlternativeBillingAvailabilityAndroid', - 'clearTransactionIOS', - 'consumePurchaseAndroid', - 'createAlternativeBillingTokenAndroid', - 'createBillingProgramReportingDetailsAndroid', - 'deepLinkToSubscriptions', - 'endConnection', - 'finishTransaction', - 'initConnection', - 'isBillingProgramAvailableAndroid', - 'launchExternalLinkAndroid', - 'presentCodeRedemptionSheetIOS', - 'presentExternalPurchaseLinkIOS', - 'presentExternalPurchaseNoticeSheetIOS', - 'requestPurchase', - 'requestPurchaseOnPromotedProductIOS', - 'restorePurchases', - 'showAlternativeBillingDialogAndroid', - 'showExternalPurchaseCustomLinkNoticeIOS', - 'showManageSubscriptionsIOS', - 'syncIOS', - 'validateReceipt', - 'verifyPurchase', - 'verifyPurchaseWithProvider', + "acknowledgePurchaseAndroid", + "beginRefundRequestIOS", + "checkAlternativeBillingAvailabilityAndroid", + "clearTransactionIOS", + "consumePurchaseAndroid", + "createAlternativeBillingTokenAndroid", + "createBillingProgramReportingDetailsAndroid", + "deepLinkToSubscriptions", + "endConnection", + "finishTransaction", + "initConnection", + "isBillingProgramAvailableAndroid", + "launchExternalLinkAndroid", + "presentCodeRedemptionSheetIOS", + "presentExternalPurchaseLinkIOS", + "presentExternalPurchaseNoticeSheetIOS", + "requestPurchase", + "requestPurchaseOnPromotedProductIOS", + "restorePurchases", + "showAlternativeBillingDialogAndroid", + "showBillingProgramInformationDialogAndroid", + "showExternalPurchaseCustomLinkNoticeIOS", + "showInAppMessagesAndroid", + "showManageSubscriptionsIOS", + "syncIOS", + "validateReceipt", + "verifyPurchase", + "verifyPurchaseWithProvider", ], Query: [ - 'canPresentExternalPurchaseNoticeIOS', - 'currentEntitlementIOS', - 'fetchProducts', - 'getActiveSubscriptions', - 'getAllTransactionsIOS', - 'getAppTransactionIOS', - 'getAvailablePurchases', - 'getExternalPurchaseCustomLinkTokenIOS', - 'getPendingTransactionsIOS', - 'getPromotedProductIOS', - 'getReceiptDataIOS', - 'getStorefront', - 'getStorefrontIOS', - 'getTransactionJwsIOS', - 'hasActiveSubscriptions', - 'isEligibleForExternalPurchaseCustomLinkIOS', - 'isEligibleForIntroOfferIOS', - 'isTransactionVerifiedIOS', - 'latestTransactionIOS', - 'subscriptionStatusIOS', - 'validateReceiptIOS', + "canPresentExternalPurchaseNoticeIOS", + "currentEntitlementIOS", + "fetchProducts", + "getActiveSubscriptions", + "getAllTransactionsIOS", + "getAppTransactionIOS", + "getAvailablePurchases", + "getBillingChoiceInfoAndroid", + "getExternalPurchaseCustomLinkTokenIOS", + "getPendingTransactionsIOS", + "getPromotedProductIOS", + "getReceiptDataIOS", + "getStorefront", + "getStorefrontIOS", + "getTransactionJwsIOS", + "hasActiveSubscriptions", + "isEligibleForExternalPurchaseCustomLinkIOS", + "isEligibleForIntroOfferIOS", + "isTransactionVerifiedIOS", + "latestTransactionIOS", + "subscriptionStatusIOS", + "validateReceiptIOS", ], Subscription: [ - 'developerProvidedBillingAndroid', - 'promotedProductIOS', - 'purchaseError', - 'purchaseUpdated', - 'subscriptionBillingIssue', - 'userChoiceBillingAndroid', + "developerProvidedBillingAndroid", + "promotedProductIOS", + "purchaseError", + "purchaseUpdated", + "subscriptionBillingIssue", + "userChoiceBillingAndroid", ], }; @@ -106,7 +109,7 @@ function abs(relativePath) { } function read(relativePath) { - return fs.readFileSync(abs(relativePath), 'utf8'); + return fs.readFileSync(abs(relativePath), "utf8"); } function readJson(relativePath) { @@ -124,7 +127,7 @@ function fail(message) { function listDirectories(relativePath) { if (!exists(relativePath)) return []; return fs - .readdirSync(abs(relativePath), {withFileTypes: true}) + .readdirSync(abs(relativePath), { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(); @@ -177,12 +180,21 @@ function expectOptionalIncludes(relativePath, needles, label = relativePath) { expectIncludes(relativePath, needles, label); } -function expectOptionalNotIncludes(relativePath, needles, label = relativePath) { +function expectOptionalNotIncludes( + relativePath, + needles, + label = relativePath, +) { if (!exists(relativePath)) return; expectNotIncludes(relativePath, needles, label); } -function expectSameFile(sourcePath, targetPath, label = targetPath, normalize = (value) => value) { +function expectSameFile( + sourcePath, + targetPath, + label = targetPath, + normalize = (value) => value, +) { expectFile(sourcePath); expectFile(targetPath); if (!exists(sourcePath) || !exists(targetPath)) return; @@ -193,7 +205,11 @@ function expectSameFile(sourcePath, targetPath, label = targetPath, normalize = } } -function expectSymlinkTarget(relativePath, expectedTarget, label = relativePath) { +function expectSymlinkTarget( + relativePath, + expectedTarget, + label = relativePath, +) { expectFile(relativePath); if (!exists(relativePath)) return; const stat = fs.lstatSync(abs(relativePath)); @@ -214,22 +230,28 @@ function expectSameSet(label, ssotValues, registryValues) { const stale = [...registry].filter((value) => !ssot.has(value)).sort(); if (missing.length > 0) { - fail(`${label} missing parity registry coverage: ${missing.join(', ')}`); + fail(`${label} missing parity registry coverage: ${missing.join(", ")}`); } if (stale.length > 0) { - fail(`${label} parity registry has stale entries: ${stale.join(', ')}`); + fail(`${label} parity registry has stale entries: ${stale.join(", ")}`); } } function uniqueMatches(text, regex) { - return [...new Set([...text.matchAll(regex)].map((match) => match[1]))].sort(); + return [ + ...new Set([...text.matchAll(regex)].map((match) => match[1])), + ].sort(); } function discoverExpoProductIds() { - const constantsPath = rel(EXPO_EXAMPLE_ROOT, 'src/utils/constants.ts'); + const constantsPath = rel(EXPO_EXAMPLE_ROOT, "src/utils/constants.ts"); expectFile(constantsPath); if (!exists(constantsPath)) return []; - const ids = [...new Set(read(constantsPath).match(/dev\.hyo\.martie\.[A-Za-z0-9._-]+/g) ?? [])].sort(); + const ids = [ + ...new Set( + read(constantsPath).match(/dev\.hyo\.martie\.[A-Za-z0-9._-]+/g) ?? [], + ), + ].sort(); if (ids.length === 0) { fail(`${constantsPath} does not declare any SSOT example product IDs`); } @@ -237,25 +259,27 @@ function discoverExpoProductIds() { } function discoverExpoRoutes() { - const appDir = rel(EXPO_EXAMPLE_ROOT, 'app'); + const appDir = rel(EXPO_EXAMPLE_ROOT, "app"); if (!exists(appDir)) { fail(`missing Expo SSOT app directory: ${appDir}`); return []; } return fs - .readdirSync(abs(appDir), {withFileTypes: true}) - .filter((entry) => entry.isFile() && entry.name.endsWith('.tsx')) - .map((entry) => entry.name.replace(/\.tsx$/, '')) - .filter((route) => !['_layout', 'index', '+not-found'].includes(route)) + .readdirSync(abs(appDir), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".tsx")) + .map((entry) => entry.name.replace(/\.tsx$/, "")) + .filter((route) => !["_layout", "index", "+not-found"].includes(route)) .sort(); } function parseGeneratedOperations(kind) { - const sourcePath = 'packages/gql/src/generated/types.ts'; + const sourcePath = "packages/gql/src/generated/types.ts"; expectFile(sourcePath); if (!exists(sourcePath)) return []; - const match = read(sourcePath).match(new RegExp(`export interface ${kind} \\{([\\s\\S]*?)\\n\\}`)); + const match = read(sourcePath).match( + new RegExp(`export interface ${kind} \\{([\\s\\S]*?)\\n\\}`), + ); if (!match) { fail(`${sourcePath} is missing export interface ${kind}`); return []; @@ -265,6 +289,233 @@ function parseGeneratedOperations(kind) { .sort(); } +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getTopLevelConstDeclaration(text, constName) { + const match = new RegExp( + `(^|\\n)(?:export\\s+)?const\\s+${escapeRegExp(constName)}\\b`, + ).exec(text); + if (!match) return ""; + + const start = match.index + match[1].length; + const rest = text.slice(start + 1); + const next = /\n(?:export\s+)?(?:const|function|class|type|interface)\s/.exec( + rest, + ); + const end = next ? start + 1 + next.index : text.length; + return text.slice(start, end); +} + +function hasTypeScriptFieldBinding(text, operation, fieldType) { + return [operation, `${operation}Field`].some((constName) => { + const declaration = getTopLevelConstDeclaration(text, constName); + return ( + declaration.includes(`${fieldType}<`) && + (declaration.includes(`'${operation}'`) || + declaration.includes(`"${operation}"`)) + ); + }); +} + +function expectTypeScriptFieldBindings(label, files, kind) { + const text = files.map((file) => read(file)).join("\n"); + const fieldType = `${kind}Field`; + const missing = parseGeneratedOperations(kind).filter( + (operation) => !hasTypeScriptFieldBinding(text, operation, fieldType), + ); + if (missing.length > 0) { + fail(`${label} missing ${fieldType} bindings: ${missing.join(", ")}`); + } +} + +function extractBetween(text, startMarker, endMarker) { + const start = text.indexOf(startMarker); + if (start < 0) return ""; + const end = endMarker + ? text.indexOf(endMarker, start + startMarker.length) + : -1; + return text.slice(start, end < 0 ? text.length : end); +} + +function expectFlutterHandlers(kind, block) { + if (!block) { + fail(`Flutter ${kind}Handlers block is missing`); + return; + } + const missing = parseGeneratedOperations(kind).filter( + (operation) => + !new RegExp(`\\b${escapeRegExp(operation)}\\s*:`).test(block), + ); + if (missing.length > 0) { + fail( + `Flutter ${kind}Handlers missing generated operations: ${missing.join(", ")}`, + ); + } +} + +function parseGodotOperationFields() { + const fields = []; + const lines = read("libraries/godot-iap/addons/godot-iap/types.gd").split( + "\n", + ); + let section = null; + let current = null; + + const flush = () => { + if (current) fields.push(current); + current = null; + }; + + for (const line of lines) { + const sectionMatch = line.match(/^class (Query|Mutation):$/); + if (sectionMatch) { + flush(); + section = sectionMatch[1]; + continue; + } + + if (/^class /.test(line)) { + flush(); + section = null; + continue; + } + + if (!section) continue; + + const fieldMatch = line.match(/^\tclass ([A-Za-z0-9_]+)Field:$/); + if (fieldMatch) { + flush(); + current = { + section, + className: fieldMatch[1], + name: "", + snakeName: "", + returnType: "", + isArray: false, + }; + continue; + } + + if (!current) continue; + + const stringConstMatch = line.match( + /^\t\tconst (name|snake_name|return_type) = "([^"]+)"$/, + ); + if (stringConstMatch) { + if (stringConstMatch[1] === "name") current.name = stringConstMatch[2]; + if (stringConstMatch[1] === "snake_name") + current.snakeName = stringConstMatch[2]; + if (stringConstMatch[1] === "return_type") + current.returnType = stringConstMatch[2]; + continue; + } + + const isArrayMatch = line.match(/^\t\tconst is_array = (true|false)$/); + if (isArrayMatch) current.isArray = isArrayMatch[1] === "true"; + } + + flush(); + return fields; +} + +function parseGodotWrapperFunctions() { + const functions = new Map(); + const source = read("libraries/godot-iap/addons/godot-iap/godot_iap.gd"); + for (const match of source.matchAll( + /^func\s+([A-Za-z_][A-Za-z0-9_]*)\([^)]*\)\s*(?:->\s*([^:]+))?:/gm, + )) { + functions.set(match[1], (match[2] ?? "").trim()); + } + return functions; +} + +function expectedGodotReturnAnnotation(field) { + if (field.isArray) return "Array"; + if (field.returnType === "Boolean") return "bool"; + if (field.returnType === "String") return "String"; + if (field.returnType === "Int") return "int"; + if (field.returnType === "Float") return "float"; + return "Variant"; +} + +function checkFrameworkOperationBindings() { + for (const kind of ["Query", "Mutation"]) { + expectTypeScriptFieldBindings( + `React Native ${kind}`, + ["libraries/react-native-iap/src/index.ts"], + kind, + ); + expectTypeScriptFieldBindings( + `Expo ${kind}`, + [ + "libraries/expo-iap/src/index.ts", + "libraries/expo-iap/src/modules/android.ts", + "libraries/expo-iap/src/modules/ios.ts", + ], + kind, + ); + } + + const flutter = read( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + ); + expectFlutterHandlers( + "Query", + extractBetween( + flutter, + "gentype.QueryHandlers get queryHandlers", + "gentype.MutationLaunchExternalLinkAndroidHandler", + ), + ); + expectFlutterHandlers( + "Mutation", + extractBetween( + flutter, + "gentype.MutationHandlers get mutationHandlers", + "gentype.SubscriptionHandlers get subscriptionHandlers", + ), + ); + expectFlutterHandlers( + "Subscription", + extractBetween( + flutter, + "gentype.SubscriptionHandlers get subscriptionHandlers", + "\n}", + ), + ); + + const godotFunctions = parseGodotWrapperFunctions(); + for (const field of parseGodotOperationFields()) { + if (field.name === "_placeholder") continue; + const actual = godotFunctions.get(field.snakeName); + if (!actual) { + fail( + `Godot wrapper missing generated ${field.section} operation ${field.name} (${field.snakeName})`, + ); + continue; + } + + if (field.snakeName === "fetch_products" && actual === "Array") { + continue; + } + + const expected = expectedGodotReturnAnnotation(field); + if (expected === "Array") { + if (!actual.startsWith("Array")) { + fail( + `Godot wrapper ${field.snakeName} should return Array for ${field.returnType}, got ${actual || "(none)"}`, + ); + } + } else if (actual !== expected) { + fail( + `Godot wrapper ${field.snakeName} should return ${expected} for ${field.returnType}, got ${actual || "(none)"}`, + ); + } + } +} + function parseKotlinResolverOperations(relativePath, kind) { expectFile(relativePath); if (!exists(relativePath)) return []; @@ -282,29 +533,37 @@ function parseKotlinResolverOperations(relativePath, kind) { function kmpGeneratedKotlin(text) { if (!/\bpackage io\.github\.hyochan\.kmpiap\.openiap\b/.test(text)) { - const lines = text.split('\n'); + const lines = text.split("\n"); let lastFileAnnotation = -1; for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('@file:')) lastFileAnnotation = i; + if (lines[i].startsWith("@file:")) lastFileAnnotation = i; } if (lastFileAnnotation >= 0) { - lines.splice(lastFileAnnotation + 1, 0, '', 'package io.github.hyochan.kmpiap.openiap'); + lines.splice( + lastFileAnnotation + 1, + 0, + "", + "package io.github.hyochan.kmpiap.openiap", + ); } else { - lines.unshift('package io.github.hyochan.kmpiap.openiap', ''); + lines.unshift("package io.github.hyochan.kmpiap.openiap", ""); } - text = lines.join('\n'); + text = lines.join("\n"); } return text.replace( /(\n\s*\w+\([^)]*\))\n\n(\s+companion object)/g, - '$1;\n\n$2', + "$1;\n\n$2", ); } function checkLibraryCoverageRegistry() { - const discovered = listDirectories('libraries'); + const discovered = listDirectories("libraries"); for (const library of discovered) { - if (parityCoveredLibraries.has(library) || parityExcludedLibraries.has(library)) { + if ( + parityCoveredLibraries.has(library) || + parityExcludedLibraries.has(library) + ) { continue; } fail( @@ -321,62 +580,180 @@ function checkLibraryCoverageRegistry() { } function checkExpoSsotRegistry() { - expectSameSet('Expo example route', discoverExpoRoutes(), routes); + expectSameSet("Expo example route", discoverExpoRoutes(), routes); if (requiredIds.length === 0) { - fail('Expo example product ID registry is empty'); + fail("Expo example product ID registry is empty"); + } +} + +function checkE2eExampleIds() { + const workflowPath = ".claude/commands/e2e-tests.md"; + const workflow = read(workflowPath); + const expoSection = extractBetween( + workflow, + "## Expo Checks", + "## React Native Checks", + ); + const normalTargets = extractBetween( + expoSection, + "Example tests, normal Android build, and launch smoke:", + "VegaOS/Kepler path:", + ); + const vegaTarget = extractBetween(expoSection, "VegaOS/Kepler path:", null); + + for (const expected of [ + "shell monkey -p dev.hyo.martie 1", + "dev.hyo.martie", + "-workspace ios/ExpoIAPExample.xcworkspace", + "-scheme ExpoIAPExample", + "Debug-iphoneos/ExpoIAPExample.app", + ]) { + if (!normalTargets.includes(expected)) { + fail(`Expo E2E normal targets are missing ${JSON.stringify(expected)}`); + } + } + if (normalTargets.includes("dev.hyo.openiap.expo.example")) { + fail( + "Expo E2E normal targets must use dev.hyo.martie, not the Vega package ID", + ); + } + for (const staleName of [ + "ios/expoiapexample.xcworkspace", + "-scheme expoiapexample", + "Debug-iphoneos/expoiapexample.app", + ]) { + if (normalTargets.includes(staleName)) { + fail( + `Expo E2E normal targets must not use stale Xcode name ${JSON.stringify(staleName)}`, + ); + } + } + for (const expected of [ + "dev.hyo.openiap.expo.example.main", + "bun run run:vega:firetv", + ]) { + if (!vegaTarget.includes(expected)) { + fail(`Expo E2E Vega target is missing ${JSON.stringify(expected)}`); + } + } + + const mauiSection = extractBetween( + workflow, + "## MAUI Checks", + "## Godot Checks", + ); + for (const expected of [ + "-p:EmbedAssembliesIntoApk=true", + 'adb -s "$ANDROID_SERIAL" uninstall dev.hyo.martie', + 'adb -s "$ANDROID_SERIAL" install --no-incremental -r', + 'adb -s "$ANDROID_SERIAL" shell monkey -p dev.hyo.martie 1', + 'adb -s "$FIREOS_SERIAL" uninstall dev.hyo.martie', + 'adb -s "$FIREOS_SERIAL" install --no-incremental -r', + "bin/stores/amazon/Debug/net9.0-android/dev.hyo.martie-Signed.apk", + 'adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1', + "-p:RuntimeIdentifier=ios-arm64", + "bin/Debug/net9.0-ios/ios-arm64/OpenIap.Maui.Example.app", + "xcrun devicectl device process launch", + ]) { + if (!mauiSection.includes(expected)) { + fail(`MAUI E2E device targets are missing ${JSON.stringify(expected)}`); + } + } + if (/ANDROID_SERIAL="\$[^\"]+"\s+dotnet build/.test(mauiSection)) { + fail( + "MAUI E2E must select Android devices with adb -s, not ANDROID_SERIAL", + ); } + if (/dotnet build\s+-t:Run\s+-f net9\.0-ios/.test(mauiSection)) { + fail("MAUI E2E must build ios-arm64 before installing with devicectl"); + } + + expectIncludes( + "libraries/godot-iap/Makefile", + ["@rm -rf $(IOS_EXPORT_DIR)", "@touch $(IOS_EXPORT_DIR)/.gdignore"], + "Godot iOS export isolation", + ); + expectIncludes( + "libraries/godot-iap/scripts/install-hooks.sh", + [ + 'git -C "$PROJECT_ROOT" rev-parse --show-toplevel', + 'if [ "$REPO_ROOT" != "$PROJECT_ROOT" ]', + 'git -C "$PROJECT_ROOT" rev-parse --git-path hooks', + ], + "Godot Git hook installation", + ); + expectNotIncludes( + "libraries/godot-iap/scripts/install-hooks.sh", + ['HOOKS_DIR="$PROJECT_ROOT/.git/hooks"'], + "Godot Git hook installation", + ); + expectIncludes( + "libraries/godot-iap/ios-gdextension/Package.swift", + [".iOS(.v17)"], + "Godot iOS deployment target", + ); + expectIncludes( + "libraries/godot-iap/Example/export_presets.cfg", + ['application/min_ios_version="17.0"'], + "Godot example iOS deployment target", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/godot.tsx", + ["iOS 17+ target"], + "Godot docs iOS deployment target", + ); } function checkGeneratedTypeSync() { expectSameFile( - 'packages/gql/src/generated/types.ts', - 'libraries/expo-iap/src/types.ts', - 'Expo generated TypeScript types', + "packages/gql/src/generated/types.ts", + "libraries/expo-iap/src/types.ts", + "Expo generated TypeScript types", ); expectSameFile( - 'packages/gql/src/generated/types.ts', - 'libraries/react-native-iap/src/types.ts', - 'React Native generated TypeScript types', + "packages/gql/src/generated/types.ts", + "libraries/react-native-iap/src/types.ts", + "React Native generated TypeScript types", ); expectSameFile( - 'packages/gql/src/webhook-client.ts', - 'libraries/expo-iap/src/webhook-client.ts', - 'Expo webhook client helper', + "packages/gql/src/webhook-client.ts", + "libraries/expo-iap/src/webhook-client.ts", + "Expo webhook client helper", ); expectSameFile( - 'packages/gql/src/webhook-client.ts', - 'libraries/react-native-iap/src/webhook-client.ts', - 'React Native webhook client helper', + "packages/gql/src/webhook-client.ts", + "libraries/react-native-iap/src/webhook-client.ts", + "React Native webhook client helper", ); expectSameFile( - 'packages/gql/src/kit-api.ts', - 'libraries/expo-iap/src/kit-api.ts', - 'Expo IAPKit API helper', + "packages/gql/src/kit-api.ts", + "libraries/expo-iap/src/kit-api.ts", + "Expo IAPKit API helper", ); expectSameFile( - 'packages/gql/src/kit-api.ts', - 'libraries/react-native-iap/src/kit-api.ts', - 'React Native IAPKit API helper', + "packages/gql/src/kit-api.ts", + "libraries/react-native-iap/src/kit-api.ts", + "React Native IAPKit API helper", ); expectSameFile( - 'packages/gql/src/generated/types.dart', - 'libraries/flutter_inapp_purchase/lib/types.dart', - 'Flutter generated Dart types', + "packages/gql/src/generated/types.dart", + "libraries/flutter_inapp_purchase/lib/types.dart", + "Flutter generated Dart types", ); expectSameFile( - 'packages/gql/src/generated/Types.swift', - 'packages/apple/Sources/Models/Types.swift', - 'Apple generated Swift types', + "packages/gql/src/generated/Types.swift", + "packages/apple/Sources/Models/Types.swift", + "Apple generated Swift types", ); expectSameFile( - 'packages/gql/src/generated/Types.cs', - 'libraries/maui-iap/src/OpenIap.Maui/Types.cs', - 'MAUI generated C# types', + "packages/gql/src/generated/Types.cs", + "libraries/maui-iap/src/OpenIap.Maui/Types.cs", + "MAUI generated C# types", ); expectSameFile( - 'packages/gql/src/generated/Types.kt', - 'libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt', - 'KMP generated Kotlin types', + "packages/gql/src/generated/Types.kt", + "libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt", + "KMP generated Kotlin types", kmpGeneratedKotlin, ); @@ -385,7 +762,7 @@ function checkGeneratedTypeSync() { `Google generated ${kind} operations`, parseGeneratedOperations(kind), parseKotlinResolverOperations( - 'packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt', + "packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt", kind, ), ); @@ -393,11 +770,11 @@ function checkGeneratedTypeSync() { } function checkGqlRuntimeExports() { - const packageJson = JSON.parse(read('packages/gql/package.json')); + const packageJson = JSON.parse(read("packages/gql/package.json")); const exports = packageJson.exports ?? {}; for (const [exportPath, filePath] of [ - ['./kit-api', './src/kit-api.ts'], - ['./webhook-client', './src/webhook-client.ts'], + ["./kit-api", "./src/kit-api.ts"], + ["./webhook-client", "./src/webhook-client.ts"], ]) { if (exports[exportPath] !== filePath) { fail( @@ -408,7 +785,9 @@ function checkGqlRuntimeExports() { } function checkOperationRegistry() { - for (const [kind, registeredOperations] of Object.entries(operationParityRegistry)) { + for (const [kind, registeredOperations] of Object.entries( + operationParityRegistry, + )) { expectSameSet( `GQL ${kind} operation`, parseGeneratedOperations(kind), @@ -419,16 +798,16 @@ function checkOperationRegistry() { function walk(dir, acc = []) { if (!fs.existsSync(dir)) return acc; - for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if ( - entry.name === 'node_modules' || - entry.name === '.gradle' || - entry.name === '.dart_tool' || - entry.name === 'build' || - entry.name === 'bin' || - entry.name === 'obj' || - entry.name === 'Pods' || - entry.name === 'DerivedData' + entry.name === "node_modules" || + entry.name === ".gradle" || + entry.name === ".dart_tool" || + entry.name === "build" || + entry.name === "bin" || + entry.name === "obj" || + entry.name === "Pods" || + entry.name === "DerivedData" ) { continue; } @@ -441,20 +820,22 @@ function walk(dir, acc = []) { function expectNoExampleStorefrontIOS() { const roots = [ - 'libraries/expo-iap/example', - 'libraries/react-native-iap/example', - 'libraries/flutter_inapp_purchase/example', - 'libraries/kmp-iap/example', - 'libraries/maui-iap/example', - 'packages/apple/Example', - 'packages/google/Example', + "libraries/expo-iap/example", + "libraries/react-native-iap/example", + "libraries/flutter_inapp_purchase/example", + "libraries/kmp-iap/example", + "libraries/maui-iap/example", + "packages/apple/Example", + "packages/google/Example", ]; for (const searchRoot of roots) { for (const file of walk(abs(searchRoot))) { if (!/\.(tsx?|dart|kt|swift|cs|xaml)$/.test(file)) continue; - const text = fs.readFileSync(file, 'utf8'); - if (text.includes('getStorefrontIOS(')) { - fail(`example uses getStorefrontIOS instead of getStorefront: ${path.relative(root, file)}`); + const text = fs.readFileSync(file, "utf8"); + if (text.includes("getStorefrontIOS(")) { + fail( + `example uses getStorefrontIOS instead of getStorefront: ${path.relative(root, file)}`, + ); } } } @@ -462,141 +843,209 @@ function expectNoExampleStorefrontIOS() { function checkExpoRouterExample(base, importSource) { for (const route of routes) { - expectFile(rel(base, 'app', `${route}.tsx`)); - } - expectIncludes(rel(base, 'app/_layout.tsx'), routes.map((route) => `name="${route}"`), `${base} layout`); - expectIncludes(rel(base, 'app/index.tsx'), routes.map((route) => `/${route}`), `${base} home`); - expectIncludes(rel(base, 'app/index.tsx'), ['getStorefront'], `${base} home`); - expectIncludes(rel(base, 'app/webhook-stream.tsx'), [ - 'connectWebhookStream', - 'Trigger test', - '/v1/webhooks/', - '/v1/webhooks/stream', - ], `${base} webhook`); - expectIncludes(rel(base, 'app/alternative-billing.tsx'), [ - 'isBillingProgramAvailableAndroid', - 'launchExternalLinkAndroid', - 'createBillingProgramReportingDetailsAndroid', - 'enableBillingProgramAndroid', - ], `${base} alternative billing`); - expectNotIncludes(rel(base, 'app/alternative-billing.tsx'), [ - 'checkAlternativeBillingAvailabilityAndroid', - 'createAlternativeBillingTokenAndroid', - ], `${base} alternative billing`); - expectIncludes(rel(base, importSource), requiredIds, `${base} product constants`); + expectFile(rel(base, "app", `${route}.tsx`)); + } + expectIncludes( + rel(base, "app/_layout.tsx"), + routes.map((route) => `name="${route}"`), + `${base} layout`, + ); + expectIncludes( + rel(base, "app/index.tsx"), + routes.map((route) => `/${route}`), + `${base} home`, + ); + expectIncludes(rel(base, "app/index.tsx"), ["getStorefront"], `${base} home`); + expectIncludes( + rel(base, "app/webhook-stream.tsx"), + [ + "connectWebhookStream", + "Trigger test", + "/v1/webhooks/", + "/v1/webhooks/stream", + ], + `${base} webhook`, + ); + expectIncludes( + rel(base, "app/alternative-billing.tsx"), + [ + "isBillingProgramAvailableAndroid", + "launchExternalLinkAndroid", + "createBillingProgramReportingDetailsAndroid", + "enableBillingProgramAndroid", + ], + `${base} alternative billing`, + ); + expectNotIncludes( + rel(base, "app/alternative-billing.tsx"), + [ + "checkAlternativeBillingAvailabilityAndroid", + "createAlternativeBillingTokenAndroid", + ], + `${base} alternative billing`, + ); + expectIncludes( + rel(base, importSource), + requiredIds, + `${base} product constants`, + ); } function checkReactNativeClassic() { - const base = 'libraries/react-native-iap/example'; + const base = "libraries/react-native-iap/example"; const screens = { - AllProducts: 'AllProducts', - PurchaseFlow: 'PurchaseFlow', - SubscriptionFlow: 'SubscriptionFlow', - AvailablePurchases: 'AvailablePurchases', - OfferCode: 'OfferCode', - AlternativeBilling: 'AlternativeBilling', - WebhookStream: 'WebhookStream', + AllProducts: "AllProducts", + PurchaseFlow: "PurchaseFlow", + SubscriptionFlow: "SubscriptionFlow", + AvailablePurchases: "AvailablePurchases", + OfferCode: "OfferCode", + AlternativeBilling: "AlternativeBilling", + WebhookStream: "WebhookStream", }; for (const [route, file] of Object.entries(screens)) { - expectFile(rel(base, 'screens', `${file}.tsx`)); - expectIncludes(rel(base, 'navigation/index.tsx'), [`name="${route}"`], `${base} navigation`); - } - expectIncludes(rel(base, 'screens/Home.tsx'), [ - 'All Products', - 'Purchase Flow', - 'Subscription Flow', - 'Available Purchases', - 'Offer Code', - 'Alternative Billing', - 'Webhook Stream', - ], `${base} home`); - expectIncludes(rel(base, 'screens/WebhookStream.tsx'), [ - 'connectWebhookStream', - 'Trigger test notification', - '/v1/webhooks/', - ], `${base} webhook`); - expectIncludes(rel(base, 'screens/AlternativeBilling.tsx'), [ - 'isBillingProgramAvailableAndroid', - 'launchExternalLinkAndroid', - 'createBillingProgramReportingDetailsAndroid', - 'enableBillingProgramAndroid', - ], `${base} alternative billing`); - expectIncludes(rel(base, 'src/utils/constants.ts'), requiredIds, `${base} product constants`); - expectIncludes(rel(base, '__tests__/screens/Home.test.tsx'), [ - 'All Products', - 'Alternative Billing', - 'Webhook Stream', - ], `${base} home tests`); - expectIncludes(rel(base, '__tests__/RnIap.test.tsx'), [ - 'getStorefront', - 'isBillingProgramAvailableAndroid', - 'launchExternalLinkAndroid', - 'createBillingProgramReportingDetailsAndroid', - ], `${base} API tests`); + expectFile(rel(base, "screens", `${file}.tsx`)); + expectIncludes( + rel(base, "navigation/index.tsx"), + [`name="${route}"`], + `${base} navigation`, + ); + } + expectIncludes( + rel(base, "screens/Home.tsx"), + [ + "All Products", + "Purchase Flow", + "Subscription Flow", + "Available Purchases", + "Offer Code", + "Alternative Billing", + "Webhook Stream", + ], + `${base} home`, + ); + expectIncludes( + rel(base, "screens/WebhookStream.tsx"), + ["connectWebhookStream", "Trigger test notification", "/v1/webhooks/"], + `${base} webhook`, + ); + expectIncludes( + rel(base, "screens/AlternativeBilling.tsx"), + [ + "isBillingProgramAvailableAndroid", + "launchExternalLinkAndroid", + "createBillingProgramReportingDetailsAndroid", + "enableBillingProgramAndroid", + ], + `${base} alternative billing`, + ); + expectIncludes( + rel(base, "src/utils/constants.ts"), + requiredIds, + `${base} product constants`, + ); + expectIncludes( + rel(base, "__tests__/screens/Home.test.tsx"), + ["All Products", "Alternative Billing", "Webhook Stream"], + `${base} home tests`, + ); + expectIncludes( + rel(base, "__tests__/RnIap.test.tsx"), + [ + "getStorefront", + "isBillingProgramAvailableAndroid", + "launchExternalLinkAndroid", + "createBillingProgramReportingDetailsAndroid", + ], + `${base} API tests`, + ); } function checkFlutter() { - const base = 'libraries/flutter_inapp_purchase/example'; + const base = "libraries/flutter_inapp_purchase/example"; const screenFiles = [ - 'all_products_screen.dart', - 'purchase_flow_screen.dart', - 'subscription_flow_screen.dart', - 'available_purchases_screen.dart', - 'offer_code_screen.dart', - 'alternative_billing_screen.dart', - 'webhook_stream_screen.dart', + "all_products_screen.dart", + "purchase_flow_screen.dart", + "subscription_flow_screen.dart", + "available_purchases_screen.dart", + "offer_code_screen.dart", + "alternative_billing_screen.dart", + "webhook_stream_screen.dart", ]; for (const file of screenFiles) { - expectFile(rel(base, 'lib/src/screens', file)); - } - expectIncludes(rel(base, 'lib/src/app.dart'), routes.map((route) => `/${route}`), `${base} routes`); - expectIncludes(rel(base, 'lib/src/screens/home_screen.dart'), [ - 'All Products', - 'Purchase Flow', - 'Subscription Flow', - 'Available Purchases', - 'Redeem Offer Code', - 'Alternative Billing', - 'Webhook Stream', - ], `${base} home`); - expectIncludes(rel(base, 'lib/src/screens/webhook_stream_screen.dart'), [ - 'connectWebhookStream', - 'Trigger test', - '/v1/webhooks/', - ], `${base} webhook`); - expectIncludes(rel(base, 'lib/src/screens/alternative_billing_screen.dart'), [ - 'isBillingProgramAvailableAndroid', - 'launchExternalLinkAndroid', - 'createBillingProgramReportingDetailsAndroid', - ], `${base} alternative billing`); - expectIncludes(rel(base, 'lib/src/constants.dart'), requiredIds, `${base} product constants`); - expectIncludes(rel(base, 'test/widget_test.dart'), [ - 'Webhook Stream', - 'Trigger test', - 'Alternative Billing', - ], `${base} widget tests`); - expectIncludes('libraries/flutter_inapp_purchase/lib/utils.dart', [ - 'compactJWS', - 'promotionalOfferJWS', - 'winBackOffer', - ], 'Flutter iOS purchase payload'); - expectIncludes('libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart', [ - 'sends advanced iOS subscription purchase fields', - 'one-time-offer-token', - 'uses Apple channel method on iOS', - 'deepLinkToSubscriptionsAndroid', - ], 'Flutter requestPurchase tests'); - expectIncludes('libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart', [ - "invokeMethod('deepLinkToSubscriptions')", - 'deepLinkToSubscriptionsAndroid', - ], 'Flutter deepLinkToSubscriptions bridge'); + expectFile(rel(base, "lib/src/screens", file)); + } + expectIncludes( + rel(base, "lib/src/app.dart"), + routes.map((route) => `/${route}`), + `${base} routes`, + ); + expectIncludes( + rel(base, "lib/src/screens/home_screen.dart"), + [ + "All Products", + "Purchase Flow", + "Subscription Flow", + "Available Purchases", + "Redeem Offer Code", + "Alternative Billing", + "Webhook Stream", + ], + `${base} home`, + ); + expectIncludes( + rel(base, "lib/src/screens/webhook_stream_screen.dart"), + ["connectWebhookStream", "Trigger test", "/v1/webhooks/"], + `${base} webhook`, + ); + expectIncludes( + rel(base, "lib/src/screens/alternative_billing_screen.dart"), + [ + "isBillingProgramAvailableAndroid", + "launchExternalLinkAndroid", + "createBillingProgramReportingDetailsAndroid", + ], + `${base} alternative billing`, + ); + expectIncludes( + rel(base, "lib/src/constants.dart"), + requiredIds, + `${base} product constants`, + ); + expectIncludes( + rel(base, "test/widget_test.dart"), + ["Webhook Stream", "Trigger test", "Alternative Billing"], + `${base} widget tests`, + ); + expectIncludes( + "libraries/flutter_inapp_purchase/lib/utils.dart", + ["compactJWS", "promotionalOfferJWS", "winBackOffer"], + "Flutter iOS purchase payload", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart", + [ + "sends advanced iOS subscription purchase fields", + "one-time-offer-token", + "uses Apple channel method on iOS", + "deepLinkToSubscriptionsAndroid", + ], + "Flutter requestPurchase tests", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + [ + "invokeMethod('deepLinkToSubscriptions')", + "deepLinkToSubscriptionsAndroid", + ], + "Flutter deepLinkToSubscriptions bridge", + ); const flutterIosPlugin = - 'libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift'; + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift"; const flutterMacosPlugin = - 'libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift'; + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift"; const flutterSwiftPackagePaths = [ - 'libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift', - 'libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift', + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift", + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift", ]; for (const flutterSwiftPackage of flutterSwiftPackagePaths) { @@ -624,1713 +1073,3080 @@ function checkFlutter() { } } - expectIncludes(flutterIosPlugin, [ - 'case "deepLinkToSubscriptions"', - 'OpenIapModule.shared.deepLinkToSubscriptions(nil)', - ], 'Flutter iOS deepLinkToSubscriptions bridge'); - expectIncludes(flutterMacosPlugin, [ - 'case "setPurchaseUpdatedListenerOptions"', - 'case "deepLinkToSubscriptions"', - 'case "getAllTransactionsIOS"', - 'case "validateReceiptIOS", "verifyPurchase"', - 'case "verifyPurchaseWithProvider"', - 'case "isEligibleForExternalPurchaseCustomLinkIOS"', - 'case "getExternalPurchaseCustomLinkTokenIOS"', - 'case "showExternalPurchaseCustomLinkNoticeIOS"', - 'subscriptionBillingIssueListener', - ], 'Flutter macOS channel parity'); - expectIncludes( - 'libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt', + expectIncludes( + flutterIosPlugin, + [ + 'case "deepLinkToSubscriptions"', + "OpenIapModule.shared.deepLinkToSubscriptions(nil)", + ], + "Flutter iOS deepLinkToSubscriptions bridge", + ); + expectIncludes( + flutterMacosPlugin, + [ + 'case "setPurchaseUpdatedListenerOptions"', + 'case "deepLinkToSubscriptions"', + 'case "getAllTransactionsIOS"', + 'case "validateReceiptIOS", "verifyPurchase"', + 'case "verifyPurchaseWithProvider"', + 'case "isEligibleForExternalPurchaseCustomLinkIOS"', + 'case "getExternalPurchaseCustomLinkTokenIOS"', + 'case "showExternalPurchaseCustomLinkNoticeIOS"', + "subscriptionBillingIssueListener", + ], + "Flutter macOS channel parity", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt", ['"setPurchaseUpdatedListenerOptions" ->'], - 'Flutter Android purchase listener option no-op', + "Flutter Android purchase listener option no-op", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/android/build.gradle", + ["com.android.billingclient:billing-ktx:"], + "Flutter Android must inherit Play Billing from openiap-google", ); - expectNotIncludes('libraries/flutter_inapp_purchase/android/build.gradle', [ - 'com.android.billingclient:billing-ktx:', - ], 'Flutter Android must inherit Play Billing from openiap-google'); for (const nativePlugin of [ - 'libraries/expo-iap/ios/ExpoIapModule.swift', + "libraries/expo-iap/ios/ExpoIapModule.swift", flutterIosPlugin, flutterMacosPlugin, ]) { - expectNotIncludes(nativePlugin, [ - 'OpenIapModule.shared.validateReceiptIOS', - 'OpenIapModule.shared.getStorefrontIOS()', - ], 'Flutter deprecated native OpenIAP calls'); - } - expectIncludes(flutterIosPlugin, [ - 'OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()', - ], 'Flutter iOS promoted purchase bridge'); - expectIncludes(flutterMacosPlugin, [ - 'OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()', - ], 'Flutter macOS promoted purchase bridge'); - expectIncludes('libraries/react-native-iap/ios/HybridRnIap.swift', [ - 'func buyPromotedProductIOS() throws -> Promise', - 'OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()', - 'throw OpenIapException.from(purchaseError)', - ], 'RN iOS promoted purchase bridge'); - expectIncludes('libraries/expo-iap/ios/ExpoIapModule.swift', [ - 'throw IapException.from(error)', - 'code: .purchaseVerificationFailed', - 'OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()', - 'try await OpenIapModule.shared.getStorefront()', - ], 'Expo iOS error/storefront bridge'); - expectIncludes('libraries/expo-iap/ios/onside/OnsideIapModule.swift', [ - 'AsyncFunction("setPurchaseUpdatedListenerOptions")', - 'AsyncFunction("getAvailableItems") { (alsoPublish: Bool, onlyIncludeActive: Bool)', - 'AsyncFunction("getStorefront")', - 'getOnsideStorefront()', - 'OnsideEvent.subscriptionBillingIssue.rawValue', - 'constants["ERROR_CODES"] = errorCodes', - ], 'Expo Onside root API bridge'); - expectNotIncludes('libraries/expo-iap/ios/onside/OnsideIapModule.swift', [ - 'private let encoder: JSONEncoder', - ], 'Expo Onside unused encoder cleanup'); - expectIncludes('libraries/expo-iap/src/index.ts', [ - 'nativeModule.USING_ONSIDE_SDK', - 'nativeModule.restorePurchases', - ], 'Expo Onside restore routing'); - expectIncludes('libraries/expo-iap/src/ExpoIapModule.ts', [ - "const ONSIDE_MARKETPLACE_ID = 'com.onside.marketplace-app'", - 'shouldUseOnsideModule()', - 'onsideModuleUnavailable', - 'return getExpoIapFallbackModule()?.[prop]', - ], 'Expo Onside native module proxy routing'); - expectIncludes('libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts', [ - 're-resolves when Onside availability changes after initial access', - 'does not repeatedly load a missing ExpoIapOnside module', - 'surfaces non-missing ExpoIap fallback errors', - ], 'Expo Onside module proxy tests'); - expectNotIncludes('libraries/expo-iap/src/index.ts', [ - 'v3.1.0', - 'Unsupported Platform', - 'Platform not supported', - ], 'Expo public API warnings/errors must stay current and consistently phrased'); - expectNotIncludes('libraries/expo-iap/CLAUDE.md', [ - 'v2.9.0', - ], 'Expo package guidance must not reference past deprecation deadlines'); - expectIncludes('libraries/react-native-iap/src/__tests__/index.test.ts', [ - 'deepLinkToSubscriptions surfaces iOS native failures', - ], 'RN deepLinkToSubscriptions error tests'); - expectNotIncludes('libraries/react-native-iap/src/index.ts', [ - "RnIapConsole.warn('[deepLinkToSubscriptions] Failed on iOS:'", - 'getActiveSubscriptions_OLD', - 'v14.4.0', - ], 'RN deepLinkToSubscriptions error handling'); - expectNotIncludes('libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart', [ - "final androidProps = type == 'inapp'", - ], 'Flutter Android requestPurchase parser'); + expectNotIncludes( + nativePlugin, + [ + "OpenIapModule.shared.validateReceiptIOS", + "OpenIapModule.shared.getStorefrontIOS()", + ], + "Flutter deprecated native OpenIAP calls", + ); + } + expectIncludes( + flutterIosPlugin, + ["OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()"], + "Flutter iOS promoted purchase bridge", + ); + expectIncludes( + flutterMacosPlugin, + ["OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()"], + "Flutter macOS promoted purchase bridge", + ); + expectIncludes( + "libraries/react-native-iap/ios/HybridRnIap.swift", + [ + "func buyPromotedProductIOS() throws -> Promise", + "OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()", + "throw OpenIapException.from(purchaseError)", + ], + "RN iOS promoted purchase bridge", + ); + expectIncludes( + "libraries/expo-iap/ios/ExpoIapModule.swift", + [ + "throw IapException.from(error)", + "code: .purchaseVerificationFailed", + "OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()", + "try await OpenIapModule.shared.getStorefront()", + ], + "Expo iOS error/storefront bridge", + ); + expectIncludes( + "libraries/expo-iap/ios/onside/OnsideIapModule.swift", + [ + 'AsyncFunction("setPurchaseUpdatedListenerOptions")', + 'AsyncFunction("getAvailableItems") { (alsoPublish: Bool, onlyIncludeActive: Bool)', + 'AsyncFunction("getStorefront")', + "getOnsideStorefront()", + "OnsideEvent.subscriptionBillingIssue.rawValue", + 'constants["ERROR_CODES"] = errorCodes', + ], + "Expo Onside root API bridge", + ); + expectNotIncludes( + "libraries/expo-iap/ios/onside/OnsideIapModule.swift", + ["private let encoder: JSONEncoder"], + "Expo Onside unused encoder cleanup", + ); + expectIncludes( + "libraries/expo-iap/src/index.ts", + ["nativeModule.USING_ONSIDE_SDK", "nativeModule.restorePurchases"], + "Expo Onside restore routing", + ); + expectIncludes( + "libraries/expo-iap/src/ExpoIapModule.ts", + [ + "const ONSIDE_MARKETPLACE_ID = 'com.onside.marketplace-app'", + "shouldUseOnsideModule()", + "onsideModuleUnavailable", + "return getExpoIapFallbackModule()?.[prop]", + ], + "Expo Onside native module proxy routing", + ); + expectIncludes( + "libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts", + [ + "re-resolves when Onside availability changes after initial access", + "does not repeatedly load a missing ExpoIapOnside module", + "surfaces non-missing ExpoIap fallback errors", + ], + "Expo Onside module proxy tests", + ); + expectNotIncludes( + "libraries/expo-iap/src/index.ts", + ["v3.1.0", "Unsupported Platform", "Platform not supported"], + "Expo public API warnings/errors must stay current and consistently phrased", + ); + expectNotIncludes( + "libraries/expo-iap/CLAUDE.md", + ["v2.9.0"], + "Expo package guidance must not reference past deprecation deadlines", + ); + expectIncludes( + "libraries/react-native-iap/src/__tests__/index.test.ts", + ["deepLinkToSubscriptions surfaces iOS native failures"], + "RN deepLinkToSubscriptions error tests", + ); + expectNotIncludes( + "libraries/react-native-iap/src/index.ts", + [ + "RnIapConsole.warn('[deepLinkToSubscriptions] Failed on iOS:'", + "getActiveSubscriptions_OLD", + "v14.4.0", + ], + "RN deepLinkToSubscriptions error handling", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + ["final androidProps = type == 'inapp'"], + "Flutter Android requestPurchase parser", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt", + [ + '"Invalid developerBillingOption: ${e.message}"', + '"Invalid subscriptionProductReplacementParams: ${e.message}"', + '"oldProductId and replacementMode are required"', + ], + "Flutter Android billing option validation", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt", + [ + 'OpenIapLog.w(TAG, "Failed to parse developerBillingOption:', + 'OpenIapLog.w(TAG, "Failed to parse subscriptionProductReplacementParams:', + ], + "Flutter Android billing option validation must not silently fall back", + ); } function checkKmp() { - const base = 'libraries/kmp-iap/example/composeApp/src'; + const base = "libraries/kmp-iap/example/composeApp/src"; for (const file of [ - 'AllProductsScreen.kt', - 'PurchaseFlowScreen.kt', - 'SubscriptionFlowScreen.kt', - 'AvailablePurchasesScreen.kt', - 'OfferCodeScreen.kt', - 'AlternativeBillingScreen.kt', - 'WebhookStreamScreen.kt', - 'ExampleProductIds.kt', - 'WebhookTestNotification.kt', + "AllProductsScreen.kt", + "PurchaseFlowScreen.kt", + "SubscriptionFlowScreen.kt", + "AvailablePurchasesScreen.kt", + "OfferCodeScreen.kt", + "AlternativeBillingScreen.kt", + "WebhookStreamScreen.kt", + "ExampleProductIds.kt", + "WebhookTestNotification.kt", ]) { - expectFile(rel(base, 'commonMain/kotlin/dev/hyo/martie/screens', file)); - } - for (const sourceSet of ['androidMain', 'iosMain', 'jvmMain']) { - expectFile(rel(base, `${sourceSet}/kotlin/dev/hyo/martie/screens/WebhookTestNotification.${sourceSet.replace('Main', '')}.kt`)); - } - expectIncludes(rel(base, 'commonMain/kotlin/dev/hyo/martie/navigation/Navigation.kt'), routes, 'KMP navigation'); - expectIncludes(rel(base, 'commonMain/kotlin/dev/hyo/martie/screens/HomeScreen.kt'), [ - 'All Products', - 'Purchase Flow', - 'Subscription Flow', - 'Available Purchases', - 'Offer Code', - 'Alternative Billing', - 'Webhook Stream', - 'getStorefront()', - ], 'KMP home'); - expectIncludes(rel(base, 'commonMain/kotlin/dev/hyo/martie/screens/WebhookStreamScreen.kt'), [ - 'connectWebhookStream', - 'triggerWebhookTestNotification', - 'Trigger test notification', - ], 'KMP webhook'); - expectIncludes(rel(base, 'commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt'), [ - 'isBillingProgramAvailableAndroid', - 'launchExternalLinkAndroid', - 'createBillingProgramReportingDetailsAndroid', - ], 'KMP alternative billing'); - expectIncludes(rel(base, 'commonMain/kotlin/dev/hyo/martie/screens/ExampleProductIds.kt'), requiredIds, 'KMP product constants'); - expectIncludes('libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt', [ - 'requestPurchaseWithPayload(params.toJson().toObjCMap())', - 'requireIosSku(params)', - 'openIapModule.verifyPurchaseWithSku(sku)', - 'filterActiveSubscriptions(result, subscriptionIds)', - ], 'KMP iOS requestPurchase bridge'); - expectNotIncludes('libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt', [ - 'TODO: iOS 15+/18+ options', - 'For now, return a basic result', - ], 'KMP iOS requestPurchase bridge'); - expectNotIncludes('libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt', [ - 'requestPurchaseOnPromotedProductIOS(): Boolean = false', - ], 'KMP Android promoted product bridge'); - expectIncludes('libraries/kmp-iap/example/gradle.properties', [ - 'kotlin.apple.xcodeCompatibility.nowarn=true', - ], 'KMP example Xcode compatibility warning suppression'); + expectFile(rel(base, "commonMain/kotlin/dev/hyo/martie/screens", file)); + } + for (const sourceSet of ["androidMain", "iosMain", "jvmMain"]) { + expectFile( + rel( + base, + `${sourceSet}/kotlin/dev/hyo/martie/screens/WebhookTestNotification.${sourceSet.replace("Main", "")}.kt`, + ), + ); + } + expectIncludes( + rel(base, "commonMain/kotlin/dev/hyo/martie/navigation/Navigation.kt"), + routes, + "KMP navigation", + ); + expectIncludes( + rel(base, "commonMain/kotlin/dev/hyo/martie/screens/HomeScreen.kt"), + [ + "All Products", + "Purchase Flow", + "Subscription Flow", + "Available Purchases", + "Offer Code", + "Alternative Billing", + "Webhook Stream", + "getStorefront()", + ], + "KMP home", + ); + expectIncludes( + rel( + base, + "commonMain/kotlin/dev/hyo/martie/screens/WebhookStreamScreen.kt", + ), + [ + "connectWebhookStream", + "triggerWebhookTestNotification", + "Trigger test notification", + ], + "KMP webhook", + ); + expectIncludes( + rel( + base, + "commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt", + ), + [ + "isBillingProgramAvailableAndroid", + "launchExternalLinkAndroid", + "createBillingProgramReportingDetailsAndroid", + ], + "KMP alternative billing", + ); + expectIncludes( + rel(base, "commonMain/kotlin/dev/hyo/martie/screens/ExampleProductIds.kt"), + requiredIds, + "KMP product constants", + ); + expectIncludes( + "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", + [ + "requestPurchaseWithPayload(params.toJson().toObjCMap())", + "requireIosSku(params)", + "openIapModule.verifyPurchaseWithSku(sku)", + "filterActiveSubscriptions(result, subscriptionIds)", + ], + "KMP iOS requestPurchase bridge", + ); + expectNotIncludes( + "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", + ["TODO: iOS 15+/18+ options", "For now, return a basic result"], + "KMP iOS requestPurchase bridge", + ); + expectNotIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt", + ["requestPurchaseOnPromotedProductIOS(): Boolean = false"], + "KMP Android promoted product bridge", + ); + expectIncludes( + "libraries/kmp-iap/example/gradle.properties", + ["kotlin.apple.xcodeCompatibility.nowarn=true"], + "KMP example Xcode compatibility warning suppression", + ); } function checkApple() { - const base = 'packages/apple'; + const base = "packages/apple"; for (const file of [ - 'AllProductsView.swift', - 'PurchaseFlowScreen.swift', - 'SubscriptionFlowScreen.swift', - 'AvailablePurchasesScreen.swift', - 'OfferCodeScreen.swift', - 'AlternativeBillingScreen.swift', - 'WebhookStreamScreen.swift', + "AllProductsView.swift", + "PurchaseFlowScreen.swift", + "SubscriptionFlowScreen.swift", + "AvailablePurchasesScreen.swift", + "OfferCodeScreen.swift", + "AlternativeBillingScreen.swift", + "WebhookStreamScreen.swift", ]) { - expectFile(rel(base, 'Example/OpenIapExample/Screens', file)); - } - expectIncludes(rel(base, 'Example/OpenIapExample/Screens/HomeScreen.swift'), [ - 'AllProductsView', - 'PurchaseFlowScreen', - 'SubscriptionFlowScreen', - 'AvailablePurchasesScreen', - 'OfferCodeScreen', - 'AlternativeBillingScreen', - 'WebhookStreamScreen', - ], 'Apple home'); - expectIncludes(rel(base, 'Example/OpenIapExample/Screens/WebhookStreamScreen.swift'), [ - '/v1/webhooks/stream/', - 'Trigger Test', - '/v1/webhooks/', - ], 'Apple webhook'); - expectIncludes(rel(base, 'Sources/OpenIapProtocol.swift'), [ - 'func getStorefront()', - 'func canPresentExternalPurchaseNoticeIOS()', - 'func presentExternalPurchaseNoticeSheetIOS()', - 'func presentExternalPurchaseLinkIOS', - 'func isEligibleForExternalPurchaseCustomLinkIOS()', - 'func getExternalPurchaseCustomLinkTokenIOS', - 'func showExternalPurchaseCustomLinkNoticeIOS', - ], 'Apple protocol'); - expectIncludes(rel(base, 'Sources/OpenIapStore.swift'), [ - 'func getStorefront()', - 'deinit {', - 'module.removeListener(token)', - 'guard listenerTokens.isEmpty else { return }', - 'func canPresentExternalPurchaseNoticeIOS()', - 'func presentExternalPurchaseNoticeSheetIOS()', - 'func presentExternalPurchaseLinkIOS', - 'func isEligibleForExternalPurchaseCustomLinkIOS()', - 'func getExternalPurchaseCustomLinkTokenIOS', - 'func showExternalPurchaseCustomLinkNoticeIOS', - ], 'Apple store'); - expectIncludes(rel(base, 'Sources/OpenIapModule+ObjC.swift'), [ - 'func requestPurchaseWithPayload', - 'OpenIapSerialization.requestPurchaseProps(from: payload)', - 'func getStorefrontWithCompletion', - 'try await requestPurchaseOnPromotedProductIOS()', - ], 'Apple ObjC purchase bridge'); - expectIncludes(rel(base, 'Tests/OpenIapTests/VerifyPurchaseTests.swift'), [ - 'testStorefrontUsesUnifiedProtocolMethod', - 'getStorefrontCallCount', - ], 'Apple tests'); - expectIncludes(rel(base, 'Example/OpenIapExample/Screens/AllProductsView.swift'), requiredIds, 'Apple product constants'); + expectFile(rel(base, "Example/OpenIapExample/Screens", file)); + } + expectIncludes( + rel(base, "Example/OpenIapExample/Screens/HomeScreen.swift"), + [ + "AllProductsView", + "PurchaseFlowScreen", + "SubscriptionFlowScreen", + "AvailablePurchasesScreen", + "OfferCodeScreen", + "AlternativeBillingScreen", + "WebhookStreamScreen", + ], + "Apple home", + ); + expectIncludes( + rel(base, "Example/OpenIapExample/Screens/WebhookStreamScreen.swift"), + ["/v1/webhooks/stream/", "Trigger Test", "/v1/webhooks/"], + "Apple webhook", + ); + expectIncludes( + rel(base, "Sources/OpenIapProtocol.swift"), + [ + "func getStorefront()", + "func canPresentExternalPurchaseNoticeIOS()", + "func presentExternalPurchaseNoticeSheetIOS()", + "func presentExternalPurchaseLinkIOS", + "func isEligibleForExternalPurchaseCustomLinkIOS()", + "func getExternalPurchaseCustomLinkTokenIOS", + "func showExternalPurchaseCustomLinkNoticeIOS", + ], + "Apple protocol", + ); + expectIncludes( + rel(base, "Sources/OpenIapStore.swift"), + [ + "func getStorefront()", + "deinit {", + "module.removeListener(token)", + "guard listenerTokens.isEmpty else { return }", + "func canPresentExternalPurchaseNoticeIOS()", + "func presentExternalPurchaseNoticeSheetIOS()", + "func presentExternalPurchaseLinkIOS", + "func isEligibleForExternalPurchaseCustomLinkIOS()", + "func getExternalPurchaseCustomLinkTokenIOS", + "func showExternalPurchaseCustomLinkNoticeIOS", + ], + "Apple store", + ); + expectIncludes( + rel(base, "Sources/OpenIapModule+ObjC.swift"), + [ + "func requestPurchaseWithPayload", + "OpenIapSerialization.requestPurchaseProps(from: payload)", + "func getStorefrontWithCompletion", + "try await requestPurchaseOnPromotedProductIOS()", + ], + "Apple ObjC purchase bridge", + ); + expectIncludes( + rel(base, "Tests/OpenIapTests/VerifyPurchaseTests.swift"), + ["testStorefrontUsesUnifiedProtocolMethod", "getStorefrontCallCount"], + "Apple tests", + ); + expectIncludes( + rel(base, "Example/OpenIapExample/Screens/AllProductsView.swift"), + requiredIds, + "Apple product constants", + ); } function checkGoogle() { - const base = 'packages/google'; + const base = "packages/google"; for (const file of [ - 'AllProductsScreen.kt', - 'PurchaseFlowScreen.kt', - 'SubscriptionFlowScreen.kt', - 'AvailablePurchasesScreen.kt', - 'OfferCodeScreen.kt', - 'AlternativeBillingScreen.kt', - 'WebhookStreamScreen.kt', + "AllProductsScreen.kt", + "PurchaseFlowScreen.kt", + "SubscriptionFlowScreen.kt", + "AvailablePurchasesScreen.kt", + "OfferCodeScreen.kt", + "AlternativeBillingScreen.kt", + "WebhookStreamScreen.kt", ]) { - expectFile(rel(base, 'Example/src/main/java/dev/hyo/martie/screens', file)); - } - expectIncludes(rel(base, 'Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt'), [ - 'all_products', - 'purchase_flow', - 'subscription_flow', - 'available_purchases', - 'offer_code', - 'alternative_billing', - 'webhook_stream', - ], 'Google home'); - expectIncludes(rel(base, 'Example/src/main/java/dev/hyo/martie/screens/WebhookStreamScreen.kt'), [ - 'triggerTestNotification', - '/v1/webhooks/', - '/v1/webhooks/stream/', - ], 'Google webhook'); - for (const flavor of ['play', 'horizon']) { - expectIncludes(rel(base, `openiap/src/${flavor}/java/dev/hyo/openiap/OpenIapModule.kt`), [ - 'getStorefront = { getStorefront() }', - 'checkAlternativeBillingAvailabilityAndroid', - 'createAlternativeBillingTokenAndroid', - 'createBillingProgramReportingDetailsAndroid', - 'isBillingProgramAvailableAndroid', - 'launchExternalLinkAndroid', - 'showAlternativeBillingDialogAndroid', - ], `Google ${flavor} module handlers`); - } - expectIncludes(rel(base, 'Example/src/main/java/dev/hyo/martie/Constants.kt'), requiredIds, 'Google product constants'); - expectIncludes(rel(base, 'openiap/src/test/java/dev/hyo/openiap/BillingProgramAndroidTest.kt'), [ - 'external-offer', - 'external-payments', - ], 'Google Billing Programs tests'); + expectFile(rel(base, "Example/src/main/java/dev/hyo/martie/screens", file)); + } + expectIncludes( + rel(base, "Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt"), + [ + "all_products", + "purchase_flow", + "subscription_flow", + "available_purchases", + "offer_code", + "alternative_billing", + "webhook_stream", + ], + "Google home", + ); + expectIncludes( + rel( + base, + "Example/src/main/java/dev/hyo/martie/screens/WebhookStreamScreen.kt", + ), + ["triggerTestNotification", "/v1/webhooks/", "/v1/webhooks/stream/"], + "Google webhook", + ); + for (const flavor of ["play", "horizon", "amazon"]) { + expectIncludes( + rel(base, `openiap/src/${flavor}/java/dev/hyo/openiap/OpenIapModule.kt`), + [ + "getStorefront = { getStorefront() }", + "checkAlternativeBillingAvailabilityAndroid", + "createAlternativeBillingTokenAndroid", + "createBillingProgramReportingDetailsAndroid", + "getBillingChoiceInfoAndroid", + "isBillingProgramAvailableAndroid", + "launchExternalLinkAndroid", + "showAlternativeBillingDialogAndroid", + "showBillingProgramInformationDialogAndroid", + "showInAppMessagesAndroid", + ], + `Google ${flavor} module handlers`, + ); + } + expectIncludes( + rel(base, "Example/src/main/java/dev/hyo/martie/Constants.kt"), + requiredIds, + "Google product constants", + ); + expectIncludes( + rel( + base, + "openiap/src/test/java/dev/hyo/openiap/BillingProgramAndroidTest.kt", + ), + ["external-offer", "external-payments"], + "Google Billing Programs tests", + ); } function checkMaui() { - const base = 'libraries/maui-iap/example/OpenIap.Maui.Example'; + const base = "libraries/maui-iap/example/OpenIap.Maui.Example"; for (const page of [ - 'AllProductsPage', - 'PurchaseFlowPage', - 'SubscriptionFlowPage', - 'AvailablePurchasesPage', - 'OfferCodePage', - 'AlternativeBillingPage', - 'WebhookStreamPage', + "AllProductsPage", + "PurchaseFlowPage", + "SubscriptionFlowPage", + "AvailablePurchasesPage", + "OfferCodePage", + "AlternativeBillingPage", + "WebhookStreamPage", ]) { - expectFile(rel(base, 'Pages', `${page}.xaml`)); - expectFile(rel(base, 'Pages', `${page}.xaml.cs`)); - } - expectIncludes(rel(base, 'Pages/HomePage.xaml'), [ - 'All Products', - 'Purchase Flow', - 'Subscription Flow', - 'Available Purchases', - 'Offer Code', - 'Alternative Billing', - 'Webhook Stream', - ], 'MAUI home'); - expectIncludes(rel(base, 'AppShell.xaml.cs'), routes, 'MAUI routes'); - expectIncludes(rel(base, 'Pages/WebhookStreamPage.xaml.cs'), [ - 'ConnectWebhookStream', - 'TriggerButton', - '/v1/webhooks/', - ], 'MAUI webhook'); - expectIncludes(rel(base, 'Pages/AlternativeBillingPage.xaml.cs'), [ - 'IsBillingProgramAvailableAndroidAsync', - 'LaunchExternalLinkAndroidAsync', - 'CreateBillingProgramReportingDetailsAndroidAsync', - ], 'MAUI alternative billing'); - expectIncludes(rel(base, 'Constants.cs'), requiredIds, 'MAUI product constants'); - expectIncludes('libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs', [ - 'requestPurchaseWithPayload:completion:', - 'getStorefrontWithCompletion:', - ], 'MAUI iOS binding'); - expectIncludes('libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs', [ - 'RequestPurchaseWithPayload', - 'RequestPurchasePayload', - '_module.GetStorefront(cb)', - 'GetActiveSubscriptionsAsync(subscriptionIds)', - ], 'MAUI iOS requestPurchase bridge'); - expectIncludes('libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/NSObjectJsonBridge.cs', [ - 'JsonObjectToDictionary', - ], 'MAUI iOS JSON bridge'); -} - + expectFile(rel(base, "Pages", `${page}.xaml`)); + expectFile(rel(base, "Pages", `${page}.xaml.cs`)); + } + expectIncludes( + rel(base, "Pages/HomePage.xaml"), + [ + "All Products", + "Purchase Flow", + "Subscription Flow", + "Available Purchases", + "Offer Code", + "Alternative Billing", + "Webhook Stream", + ], + "MAUI home", + ); + expectIncludes(rel(base, "AppShell.xaml.cs"), routes, "MAUI routes"); + expectIncludes( + rel(base, "Pages/WebhookStreamPage.xaml.cs"), + ["ConnectWebhookStream", "TriggerButton", "/v1/webhooks/"], + "MAUI webhook", + ); + expectIncludes( + rel(base, "Pages/AlternativeBillingPage.xaml.cs"), + [ + "IsBillingProgramAvailableAndroidAsync", + "LaunchExternalLinkAndroidAsync", + "CreateBillingProgramReportingDetailsAndroidAsync", + ], + "MAUI alternative billing", + ); + expectIncludes( + rel(base, "Constants.cs"), + requiredIds, + "MAUI product constants", + ); + expectIncludes( + "libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs", + ["requestPurchaseWithPayload:completion:", "getStorefrontWithCompletion:"], + "MAUI iOS binding", + ); + expectIncludes( + "libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs", + [ + "RequestPurchaseWithPayload", + "RequestPurchasePayload", + "_module.GetStorefront(cb)", + "GetActiveSubscriptionsAsync(subscriptionIds)", + ], + "MAUI iOS requestPurchase bridge", + ); + expectIncludes( + "libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/NSObjectJsonBridge.cs", + ["JsonObjectToDictionary"], + "MAUI iOS JSON bridge", + ); +} + function checkNativeApis() { - expectIncludes('libraries/react-native-iap/src/specs/RnIap.nitro.ts', [ - 'getStorefront(): Promise', - 'checkAlternativeBillingAvailabilityAndroid(): Promise', - 'createAlternativeBillingTokenAndroid', - 'isBillingProgramAvailableAndroid', - 'createBillingProgramReportingDetailsAndroid', - 'launchExternalLinkAndroid', - ], 'RN native spec'); - expectIncludes('libraries/expo-iap/src/index.ts', [ - 'getStorefront', - 'connectWebhookStream', - ], 'Expo API exports'); - expectIncludes('libraries/flutter_inapp_purchase/lib/types.dart', [ - 'Future getStorefront()', - 'Future checkAlternativeBillingAvailabilityAndroid()', - 'Future createAlternativeBillingTokenAndroid()', - 'Future createBillingProgramReportingDetailsAndroid', - 'Future isBillingProgramAvailableAndroid', - 'Future launchExternalLinkAndroid', - ], 'Flutter generated API'); - expectIncludes('libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt', [ - 'suspend fun getStorefront(): String', - 'suspend fun checkAlternativeBillingAvailabilityAndroid(): Boolean', - 'suspend fun createAlternativeBillingTokenAndroid', - 'suspend fun createBillingProgramReportingDetailsAndroid', - 'suspend fun isBillingProgramAvailableAndroid', - 'suspend fun launchExternalLinkAndroid', - ], 'KMP generated API'); + expectIncludes( + "libraries/react-native-iap/src/specs/RnIap.nitro.ts", + [ + "getStorefront(): Promise", + "checkAlternativeBillingAvailabilityAndroid(): Promise", + "createAlternativeBillingTokenAndroid", + "isBillingProgramAvailableAndroid", + "createBillingProgramReportingDetailsAndroid", + "getBillingChoiceInfoAndroid", + "launchExternalLinkAndroid", + "showBillingProgramInformationDialogAndroid", + "showInAppMessagesAndroid", + ], + "RN native spec", + ); + expectIncludes( + "libraries/expo-iap/src/index.ts", + [ + "getStorefront", + "connectWebhookStream", + "export * from './modules/android';", + ], + "Expo API exports", + ); + expectIncludes( + "libraries/expo-iap/src/modules/android.ts", + [ + "getBillingChoiceInfoAndroid", + "showBillingProgramInformationDialogAndroid", + "showInAppMessagesAndroid", + ], + "Expo Android API exports", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/lib/types.dart", + [ + "Future getStorefront()", + "Future checkAlternativeBillingAvailabilityAndroid()", + "Future createAlternativeBillingTokenAndroid()", + "Future createBillingProgramReportingDetailsAndroid", + "Future getBillingChoiceInfoAndroid", + "Future isBillingProgramAvailableAndroid", + "Future launchExternalLinkAndroid", + "Future showBillingProgramInformationDialogAndroid", + "Future showInAppMessagesAndroid", + ], + "Flutter generated API", + ); + expectIncludes( + "libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt", + [ + "suspend fun getStorefront(): String", + "suspend fun checkAlternativeBillingAvailabilityAndroid(): Boolean", + "suspend fun createAlternativeBillingTokenAndroid", + "suspend fun createBillingProgramReportingDetailsAndroid", + "suspend fun getBillingChoiceInfoAndroid", + "suspend fun isBillingProgramAvailableAndroid", + "suspend fun launchExternalLinkAndroid", + "suspend fun showBillingProgramInformationDialogAndroid", + "suspend fun showInAppMessagesAndroid", + ], + "KMP generated API", + ); +} + +function checkBillingChoiceFieldBindings() { + expectIncludes( + "packages/docs/src/pages/docs/apis/android/show-billing-program-information-dialog-android.tsx", + [ + "a developer-rendered, in-app flow", + "Google-rendered and external-link flows do not use this step", + ], + "Billing Choice information dialog docs", + ); + expectNotIncludes( + "packages/docs/src/pages/docs/apis/android/show-billing-program-information-dialog-android.tsx", + [ + "for Google-rendered Billing Choice flows", + "when Google renders the Billing Choice screen", + ], + "Billing Choice information dialog docs", + ); + expectIncludes( + "libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt", + [ + "BillingChoiceScreenTypeAndroid.DeveloperRendered", + "DeveloperBillingTypeAndroid.InApp", + "fun showAndroidBillingMessages()", + ], + "KMP Billing Choice example flow", + ); + expectNotIncludes( + "libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt", + [ + "val developerBillingType = if (availability.isExternalLinkAvailable == true)", + ], + "KMP Billing Choice example flow", + ); + expectIncludes( + ".claude/guides/05-google-package.md", + ["Play Billing 9.1.0", "Horizon SDK 2.0.0", "Amazon Appstore SDK 3.0.8"], + "Google package guide store versions", + ); + expectNotIncludes( + ".claude/guides/05-google-package.md", + ["Play Billing 8.x"], + "Google package guide current Play Billing version", + ); + expectIncludes( + "packages/docs/src/lib/images.ts", + ["Play Billing 9.1 coverage"], + "React Native docs current Play Billing coverage", + ); + expectNotIncludes( + "packages/docs/src/lib/images.ts", + ["Play Billing 8 coverage"], + "React Native docs stale Play Billing coverage", + ); + expectNotIncludes( + "packages/google/ALTERNATIVE_BILLING.md", + ["this library uses 8.0.0"], + "Google Play Billing runtime documentation", + ); + expectIncludes( + "packages/gql/src/api-android.graphql", + [ + "OpenIAP availability: Spec 2.1.0 / openiap-google 2.3.0", + "requires Play Billing 9.1.0+", + ], + "Billing Choice OpenIAP-first API availability", + ); + expectIncludes( + "packages/gql/src/type-android.graphql", + [ + "OpenIAP Spec 2.1.0 / openiap-google 2.3.0", + "upstream API available since Play Billing 4.1.0", + ], + "Billing Choice OpenIAP-first type availability", + ); + expectIncludes( + ".claude/commands/e2e-tests.md", + ["cd libraries/react-native-iap", "yarn specs", "yarn lint:tsc"], + "React Native E2E Nitro generation", + ); + expectIncludes( + "packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt", + [ + "billingChoiceScreenType = config?.billingChoiceScreenTypeAndroid", + "billingChoiceScreenType != BillingChoiceScreenTypeAndroid.DeveloperRendered", + "setOriginalExternalTransactionId", + "params.externalTransactionToken", + 'getMethod("getLinkUri")', + 'getMethod("getOriginalExternalTransactionId")', + 'getMethod("getProducts")', + "?.takeIf { it.isNotBlank() }", + "DeveloperProvidedBillingProductAndroid(", + "BillingClient.BillingProgram.BILLING_CHOICE", + "BillingProgramReportingDetailsParams.DeveloperBillingType.IN_APP", + "InAppMessageParams.InAppMessageCategoryId.TRANSACTIONAL", + "pendingBillingPrograms", + "resolveBillingProgramsForConnection(", + "resolveLegacySubscriptionReplacementMode(", + ], + "Google Billing Choice field bindings", + ); + expectIncludes( + "packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt", + [ + "Meta Horizon does not support Google Play Billing Program reporting details", + "Meta Horizon does not support Google Play Billing Choice", + "Meta Horizon does not support Google Play billing in-app messages", + "throw OpenIapError.MissingCurrentActivity", + ], + "Horizon unsupported Google Billing APIs", + ); + expectNotIncludes( + "packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt", + [ + 'externalTransactionToken = ""', + "InAppMessageResponseCodeAndroid.NoActionNeeded", + ], + "Horizon unsupported Google Billing APIs must not report success", + ); + expectIncludes( + "packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingResultConverters.kt", + [ + "onPurchasesUpdatedSubResponseCode.toOpenIapSubResponseCode()", + "PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS", + "USER_INELIGIBLE", + ], + "Google BillingResult sub-response mapping", + ); + expectIncludes( + "packages/google/openiap/src/testPlay/java/dev/hyo/openiap/utils/BillingResultConvertersTest.kt", + [ + "maps every Play Billing 9_1 sub-response code", + "unknown sub-response code remains absent", + ], + "Google BillingResult sub-response tests", + ); + expectIncludes( + "packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/CommonHelpers.kt", + [ + "!purchaseToken.isNullOrBlank() && originalExternalTransactionId.isNullOrBlank()", + "if (hasProductLevelReplacementParams) null else replacementMode", + "pendingPrograms.filterNot { it == BillingProgramAndroid.Unspecified }", + ], + "Google developer-billed subscription replacement mode", + ); + expectIncludes( + "packages/google/openiap/src/test/java/dev/hyo/openiap/BillingChoiceAndroidTypesTest.kt", + [ + "developer billed replacement does not inject a Play replacement mode", + "pre-init billing programs survive connection config reset", + ], + "Google Billing Choice replacement tests", + ); + expectNotIncludes( + "packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt", + [ + "RequestPurchasePropsByPlatforms(android =", + "RequestSubscriptionPropsByPlatforms(android =", + ], + "Google ViewModel must use canonical google request fields", + ); + + expectIncludes( + "libraries/react-native-iap/src/specs/RnIap.nitro.ts", + [ + "originalExternalTransactionId?:", + "developerBillingOption?:", + "externalTransactionToken?: string | null;", + "subResponseCode?: SubResponseCodeAndroid | null;", + ], + "RN Billing Choice native spec fields", + ); + expectIncludes( + "libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt", + [ + "billingChoiceScreenTypeAndroid = configValue.billingChoiceScreenTypeAndroid", + "developerBillingOption = developerBillingOption", + "originalExternalTransactionId = androidRequest.originalExternalTransactionId", + "externalTransactionToken = params.externalTransactionToken.unwrapString()", + "linkUri = details.linkUri.wrapVariant()", + "originalExternalTransactionId = details.originalExternalTransactionId.wrapVariant()", + "products = details.products.map", + "subResponseCode = mapSubResponseCode(result.subResponseCode)", + ], + "RN Billing Choice Android bridge fields", + ); + expectIncludes( + "libraries/react-native-iap/src/__tests__/index.test.ts", + [ + "passes developer-rendered Billing Choice config to native", + "forwards Billing Choice external transaction token", + "originalExternalTransactionId", + "'no-applicable-sub-response-code'", + ], + "RN Billing Choice bridge tests", + ); + expectIncludes( + "libraries/react-native-iap/src/index.ts", + ["externalTransactionToken: params.externalTransactionToken"], + "RN Billing Choice public API fields", + ); + expectIncludes( + "libraries/react-native-iap/src/hooks/useIAP.ts", + [ + "billingChoiceScreenTypeAndroid?: BillingChoiceScreenTypeAndroid", + "developerProvidedBillingListenerAndroid", + "onDeveloperProvidedBillingAndroid", + "getBillingChoiceInfoAndroid?: QueryField<'getBillingChoiceInfoAndroid'>", + "showInAppMessagesAndroid?: MutationField<'showInAppMessagesAndroid'>", + ], + "RN Billing Choice hook wiring", + ); + expectIncludes( + "libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts", + [ + "passes the Billing Choice renderer to initConnection on Android", + "forwards developer-provided Billing Choice events from the hook", + "exposes all Billing Choice APIs through the hook", + ], + "RN Billing Choice hook tests", + ); + + expectIncludes( + "libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt", + [ + "InitConnectionConfig.fromJson(it)", + "developerBillingOption = parsedParams.developerBillingOption", + "originalExternalTransactionId = parsedParams.originalExternalTransactionId", + "externalTransactionToken = externalTransactionToken", + "promise.resolve(result.toJson())", + ], + "Expo Billing Choice Android bridge fields", + ); + expectIncludes( + "libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt", + ["eventDeveloperProvidedBilling", "details.toJson()"], + "Expo Billing Choice event payload", + ); + expectIncludes( + "libraries/expo-iap/src/__tests__/index.test.ts", + [ + "forwards developer-rendered Billing Choice connection config", + "originalExternalTransactionId", + ], + "Expo Billing Choice public API tests", + ); + expectIncludes( + "libraries/expo-iap/src/modules/__tests__/android.test.ts", + [ + "forwards the Billing Choice external transaction token", + "subResponseCode: 'no-applicable-sub-response-code'", + ], + "Expo Billing Choice Android API tests", + ); + expectIncludes( + "libraries/expo-iap/src/useIAP.ts", + [ + "enableBillingProgramAndroid?: BillingProgramAndroid", + "billingChoiceScreenTypeAndroid?: BillingChoiceScreenTypeAndroid", + "developerProvidedBillingListenerAndroid", + "subscriptionBillingIssueListener", + "getBillingChoiceInfoAndroid: QueryField<'getBillingChoiceInfoAndroid'>", + "showInAppMessagesAndroid: MutationField<'showInAppMessagesAndroid'>", + ], + "Expo Billing Choice hook wiring", + ); + expectIncludes( + "libraries/expo-iap/src/__tests__/useIAP.test.tsx", + [ + "forwards renderer config and billing event callbacks", + "exposes all Billing Choice APIs through the hook", + ], + "Expo Billing Choice hook tests", + ); + + expectIncludes( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + [ + "billingChoiceScreenTypeAndroid", + "payload['originalExternalTransactionId']", + "payload['developerBillingOption']", + "'externalTransactionToken': params.externalTransactionToken", + ], + "Flutter Billing Choice Dart bridge fields", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt", + [ + 'configMap["billingChoiceScreenTypeAndroid"]', + "originalExternalTransactionId = originalExternalTransactionId", + "developerBillingOption = developerBillingOption", + "externalTransactionToken = externalTransactionToken", + 'put("subResponseCode", result.subResponseCode?.toJson())', + ], + "Flutter Billing Choice Android bridge fields", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart", + [ + "BillingChoiceScreenTypeAndroid.DeveloperRendered", + "'originalExternalTransactionId'", + "'externalTransactionToken'", + "details.products.single.id", + "SubResponseCodeAndroid.NoApplicableSubResponseCode", + ], + "Flutter Billing Choice channel tests", + ); + + expectIncludes( + "libraries/godot-iap/addons/godot-iap/godot_iap.gd", + [ + "func get_billing_choice_info_android(params) -> Variant:", + "func show_billing_program_information_dialog_android(params) -> Variant:", + "func show_in_app_messages_android(params = null) -> Variant:", + "initConnectionWithConfig", + '"originalExternalTransactionId"', + '"developerBillingOption"', + "JSON.stringify(params.to_dict())", + ], + "Godot Billing Choice wrapper fields", + ); + expectIncludes( + "libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt", + [ + "fun getBillingChoiceInfoAndroid(paramsJson: String): String", + "fun showBillingProgramInformationDialogAndroid(paramsJson: String): String", + "fun showInAppMessagesAndroid(paramsJson: String): String", + "InitConnectionConfig.fromJson(", + "addDeveloperProvidedBillingListener", + "originalExternalTransactionId = params.originalExternalTransactionId", + "externalTransactionToken = externalTransactionToken", + 'emitSignal("developer_provided_billing", JSONObject(details.toJson()).toString())', + 'put("subResponseCode", result.subResponseCode?.toJson())', + ], + "Godot Billing Choice Android bridge fields", + ); + expectIncludes( + "libraries/godot-iap/Example/tests/test_godot_iap.gd", + [ + "billing_choice_screen_type_android", + "originalExternalTransactionId", + "developerBillingOption", + ], + "Godot Billing Choice wrapper tests", + ); + expectIncludes( + "packages/docs/src/pages/docs/types/billing-programs.tsx", + ["iap.developer_provided_billing_android.connect"], + "Godot developer-provided billing docs signal", + ); + expectIncludes( + "packages/docs/src/pages/docs/types/alternative-billing-types.tsx", + ["iap.user_choice_billing_android.connect"], + "Godot user choice billing docs signal", + ); + expectIncludes( + "packages/docs/src/pages/docs/features/external-purchase.tsx", + [ + "iap.user_choice_billing_android.connect", + "iap.developer_provided_billing_android.connect", + ], + "Godot external purchase docs signals", + ); + for (const path of [ + "packages/docs/src/pages/docs/types/billing-programs.tsx", + "packages/docs/src/pages/docs/types/alternative-billing-types.tsx", + "packages/docs/src/pages/docs/features/external-purchase.tsx", + ]) { + expectNotIncludes( + path, + [ + "iap.developer_provided_billing.connect", + "iap.user_choice_billing.connect", + ], + "Godot billing docs stale signals", + ); + } + + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt", + [ + "billingChoiceScreenType = config?.billingChoiceScreenTypeAndroid", + "includeDeveloperListener =", + "setOriginalExternalTransactionId", + "params.externalTransactionToken", + 'invokeOptionalStringGetter("getExternalTransactionToken")', + 'invokeOptionalStringGetter("getLinkUri")', + 'invokeOptionalStringGetter("getOriginalExternalTransactionId")', + 'invokeOptionalListGetter("getProducts")', + "runCatching { javaClass.getMethod(methodName)", + "?.takeIf { it.isNotBlank() }", + "subResponseCode = onPurchasesUpdatedSubResponseCode.toOpenIapSubResponseCode()", + "BillingClient.BillingProgram.BILLING_CHOICE", + "BillingProgramReportingDetailsParams.DeveloperBillingType.IN_APP", + "InAppMessageParams.InAppMessageCategoryId.TRANSACTIONAL", + "hasProductLevelReplacementParams = subscriptionProductReplacementParams != null", + "continuation.resume(false)", + ], + "KMP Billing Choice Android bindings", + ); + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt", + [ + "OnPurchasesUpdatedSubResponseCode.NO_APPLICABLE_SUB_RESPONSE_CODE", + "OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS", + "OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE", + ], + "KMP BillingResult sub-response mapping", + ); + expectIncludes( + "libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingResultMappingTest.kt", + [ + "maps every Play Billing 9_1 sub-response code", + "unknown sub-response code remains absent", + ], + "KMP BillingResult sub-response tests", + ); + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt", + [ + 'failUnsupported("Google Play billing in-app messages are unavailable on $storeName.")', + ], + "KMP non-Play in-app message behavior", + ); + expectNotIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt", + ["InAppMessageResponseCodeAndroid.NoActionNeeded"], + "KMP non-Play in-app messages must not report success", + ); + expectIncludes( + "libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/UnsupportedGoogleApisTest.kt", + [ + "non-Play stores reject Google in-app messages", + "ErrorCode.FeatureNotSupported", + ], + "KMP non-Play in-app message tests", + ); + expectIncludes( + "libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/SubscriptionReplacementResolutionTest.kt", + [ + "legacy replacement mode follows native Google precedence", + "hasProductLevelReplacementParams = true", + ], + "KMP subscription replacement precedence tests", + ); + expectIncludes( + "packages/docs/src/pages/docs/updates/releases.tsx", + [ + "Horizon uses Billing Compatibility SDK 2.0.0", + "com.meta.horizon.platform.HORIZON_APP_ID", + ], + "Horizon 2.0 release notes", + ); + expectIncludes( + "packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt", + [ + "com.meta.horizon.platform.HORIZON_APP_ID", + "com.meta.horizon.platform.ovr.OCULUS_APP_ID", + "com.meta.horizon.platform.ovr.HORIZON_APP_ID", + "com.oculus.vr.APP_ID", + "resolveHorizonAppId(appInfo.metaData)", + ], + "Horizon App ID canonical and migration keys", + ); + expectIncludes( + "packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonAppIdMetadataTest.kt", + [ + "canonical Horizon 2 key takes precedence over legacy metadata", + "blank canonical key falls back through every historical key", + "missing or blank Horizon metadata resolves to null", + ], + "Horizon App ID metadata tests", + ); + expectIncludes( + "libraries/expo-iap/plugin/src/withIAP.ts", + [ + "com.meta.horizon.platform.HORIZON_APP_ID", + "LEGACY_HORIZON_APP_ID_META_DATA_NAMES", + ], + "Expo Horizon App ID canonical metadata", + ); + for (const file of [ + "packages/google/Example/src/main/AndroidManifest.xml", + "libraries/react-native-iap/example/android/app/src/main/AndroidManifest.xml", + "libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml", + "packages/docs/src/pages/docs/setup/store/horizon.tsx", + ]) { + expectIncludes( + file, + ["com.meta.horizon.platform.HORIZON_APP_ID"], + "Horizon App ID canonical setup", + ); + expectNotIncludes( + file, + [ + "com.meta.horizon.platform.ovr.OCULUS_APP_ID", + "com.meta.horizon.platform.ovr.HORIZON_APP_ID", + ], + "Horizon setup must not recommend legacy metadata keys", + ); + } + expectNotIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt", + ["Full implementation uses Google Play Billing Library 8.2.1"], + "KMP Play Billing runtime documentation", + ); + expectIncludes( + "libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/dsl/PurchaseDsl.kt", + [ + "originalExternalTransactionId", + "developerBillingOption", + "subscriptionProductReplacementParams", + ], + "KMP Billing Choice purchase DSL fields", + ); + + expectIncludes( + "libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt", + [ + "fun getBillingChoiceInfoAndroid(paramsJson: String, callback: ResultCallback)", + "fun showBillingProgramInformationDialogAndroid(paramsJson: String, callback: ResultCallback)", + "fun showInAppMessagesAndroid(paramsJson: String?, callback: ResultCallback)", + "InitConnectionConfig.fromJson(parseMap(it))", + "RequestPurchaseProps.fromJson(parseMap(paramsJson))", + "LaunchExternalLinkParamsAndroid.fromJson(parseMap(paramsJson))", + "gson.toJson(details.toJson())", + "gson.toJson(module.showBillingProgramInformationDialog(activity, params).toJson())", + ], + "MAUI Billing Choice Kotlin JSON bridge", + ); + expectIncludes( + "libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs", + [ + "GetBillingChoiceInfoAndroidAsync(GetBillingChoiceInfoParamsAndroid @params)", + "ShowBillingProgramInformationDialogAndroidAsync(BillingProgramInformationDialogParamsAndroid @params)", + "ShowInAppMessagesAndroidAsync(InAppMessageParamsAndroid? @params = null)", + "JsonSerializer.Serialize(config, JsonOptions.Default)", + "JsonSerializer.Serialize(@params, JsonOptions.Default)", + ], + "MAUI Billing Choice C# JSON bridge", + ); + expectIncludes( + "libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs", + ["JsonSerializer.Deserialize"], + "MAUI Billing Choice event payload", + ); } function checkFrameworkDependencyHygiene() { - const versions = readJson('openiap-versions.json'); + const versions = readJson("openiap-versions.json"); const googleVersion = versions.google; - if (typeof googleVersion !== 'string' || googleVersion.length === 0) { - fail('openiap-versions.json is missing a google version'); + if (typeof googleVersion !== "string" || googleVersion.length === 0) { + fail("openiap-versions.json is missing a google version"); } - const googleBuildGradle = read('packages/google/openiap/build.gradle.kts'); + const googleBuildGradle = read("packages/google/openiap/build.gradle.kts"); const googleCoroutineVersions = uniqueMatches( googleBuildGradle, /val\s+coroutinesVersion\s*=\s*"([^"]+)"/g, ); if (googleCoroutineVersions.length !== 1) { fail( - `packages/google must use one Kotlinx Coroutines version, found: ${googleCoroutineVersions.join(', ') || '(none)'}`, + `packages/google must use one Kotlinx Coroutines version, found: ${googleCoroutineVersions.join(", ") || "(none)"}`, ); } const googleCoroutinesVersion = googleCoroutineVersions[0]; for (const [packagePath, versionKey] of [ - ['packages/gql/package.json', 'spec'], - ['packages/docs/package.json', 'spec'], - ['packages/google/package.json', 'google'], - ['packages/apple/package.json', 'apple'], + ["packages/gql/package.json", "spec"], + ["packages/docs/package.json", "spec"], + ["packages/google/package.json", "google"], + ["packages/apple/package.json", "apple"], ]) { const packageVersion = readJson(packagePath).version; if (packageVersion !== versions[versionKey]) { - fail(`${packagePath} version ${packageVersion} must match openiap-versions.json ${versionKey} ${versions[versionKey]}`); + fail( + `${packagePath} version ${packageVersion} must match openiap-versions.json ${versionKey} ${versions[versionKey]}`, + ); } } expectSymlinkTarget( - 'packages/apple/Sources/openiap-versions.json', - '../../../openiap-versions.json', - 'Apple package version SSOT link', + "packages/apple/Sources/openiap-versions.json", + "../../../openiap-versions.json", + "Apple package version SSOT link", ); expectSymlinkTarget( - 'packages/google/openiap-versions.json', - '../../openiap-versions.json', - 'Google package version SSOT link', + "packages/google/openiap-versions.json", + "../../openiap-versions.json", + "Google package version SSOT link", ); for (const libraryVersionLink of [ - 'libraries/react-native-iap/openiap-versions.json', - 'libraries/expo-iap/openiap-versions.json', - 'libraries/flutter_inapp_purchase/openiap-versions.json', - 'libraries/godot-iap/openiap-versions.json', - 'libraries/kmp-iap/openiap-versions.json', - 'libraries/maui-iap/openiap-versions.json', + "libraries/react-native-iap/openiap-versions.json", + "libraries/expo-iap/openiap-versions.json", + "libraries/flutter_inapp_purchase/openiap-versions.json", + "libraries/godot-iap/openiap-versions.json", + "libraries/kmp-iap/openiap-versions.json", + "libraries/maui-iap/openiap-versions.json", ]) { expectSymlinkTarget( libraryVersionLink, - '../../openiap-versions.json', + "../../openiap-versions.json", `${libraryVersionLink} version SSOT link`, ); } - expectFile('packages/docs/openiap-versions.json'); + expectFile("packages/docs/openiap-versions.json"); if ( - exists('packages/docs/openiap-versions.json') && - fs.lstatSync(abs('packages/docs/openiap-versions.json')).isSymbolicLink() + exists("packages/docs/openiap-versions.json") && + fs.lstatSync(abs("packages/docs/openiap-versions.json")).isSymbolicLink() ) { - fail('packages/docs/openiap-versions.json must be a real file for Vercel deployment'); + fail( + "packages/docs/openiap-versions.json must be a real file for Vercel deployment", + ); } expectSameFile( - 'openiap-versions.json', - 'packages/docs/openiap-versions.json', - 'Docs package version copy', + "openiap-versions.json", + "packages/docs/openiap-versions.json", + "Docs package version copy", ); expectSymlinkTarget( - 'llms.txt', - 'packages/docs/public/llms.txt', - 'Root llms.txt', + "llms.txt", + "packages/docs/public/llms.txt", + "Root llms.txt", ); expectSymlinkTarget( - 'llms-full.txt', - 'packages/docs/public/llms-full.txt', - 'Root llms-full.txt', + "llms-full.txt", + "packages/docs/public/llms-full.txt", + "Root llms-full.txt", ); for (const docsLlmsFile of [ - 'packages/docs/public/llms.txt', - 'packages/docs/public/llms-full.txt', + "packages/docs/public/llms.txt", + "packages/docs/public/llms-full.txt", ]) { expectFile(docsLlmsFile); - if (exists(docsLlmsFile) && fs.lstatSync(abs(docsLlmsFile)).isSymbolicLink()) { + if ( + exists(docsLlmsFile) && + fs.lstatSync(abs(docsLlmsFile)).isSymbolicLink() + ) { fail(`${docsLlmsFile} must be a real file for docs deployment`); } } - expectFile('packages/docs/src/generated/version-metadata.json'); - if (exists('packages/docs/src/generated/version-metadata.json')) { - const docsVersionMetadata = readJson('packages/docs/src/generated/version-metadata.json'); + expectFile("packages/docs/src/generated/version-metadata.json"); + if (exists("packages/docs/src/generated/version-metadata.json")) { + const docsVersionMetadata = readJson( + "packages/docs/src/generated/version-metadata.json", + ); const expectedDocsVersionMetadata = { - _generatedBy: 'scripts/sync-versions.sh', - expoPackageVersion: readJson('libraries/expo-iap/package.json').version, - reactNativePackageVersion: readJson('libraries/react-native-iap/package.json').version, - flutterPackageVersion: read('libraries/flutter_inapp_purchase/pubspec.yaml') + _generatedBy: "scripts/sync-versions.sh", + expoPackageVersion: readJson("libraries/expo-iap/package.json").version, + reactNativePackageVersion: readJson( + "libraries/react-native-iap/package.json", + ).version, + flutterPackageVersion: read( + "libraries/flutter_inapp_purchase/pubspec.yaml", + ) .match(/^version:\s*(.+)$/m)?.[1] ?.trim(), - godotPackageVersion: read('libraries/godot-iap/addons/godot-iap/plugin.cfg') + godotPackageVersion: read( + "libraries/godot-iap/addons/godot-iap/plugin.cfg", + ) .match(/^version="([^"]+)"$/m)?.[1] ?.trim(), - kmpPackageVersion: read('libraries/kmp-iap/gradle.properties') + kmpPackageVersion: read("libraries/kmp-iap/gradle.properties") .match(/^libraryVersion=(.+)$/m)?.[1] ?.trim(), - mauiPackageId: read('libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj') + mauiPackageId: read( + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + ) .match(/([^<]+)<\/PackageId>/)?.[1] ?.trim(), - mauiPackageVersion: read('libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj') + mauiPackageVersion: read( + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + ) .match(/([^<]+)<\/PackageVersion>/)?.[1] ?.trim(), - googleCompileSdk: read('packages/google/openiap/build.gradle.kts') - .match(/compileSdk\s*=\s*(\d+)/)?.[1], - googleMinSdk: read('packages/google/openiap/build.gradle.kts') - .match(/minSdk\s*=\s*(\d+)/)?.[1], - googlePlayBillingVersion: read('packages/google/openiap/build.gradle.kts') - .match(/val\s+playBillingVersion\s*=\s*"([^"]+)"/)?.[1], - kmpCompileSdk: read('libraries/kmp-iap/gradle/libs.versions.toml') - .match(/^android-compileSdk = "([^"]+)"/m)?.[1], - kmpMinSdk: read('libraries/kmp-iap/gradle/libs.versions.toml') - .match(/^android-minSdk = "([^"]+)"/m)?.[1], - kmpTargetSdk: read('libraries/kmp-iap/gradle/libs.versions.toml') - .match(/^android-targetSdk = "([^"]+)"/m)?.[1], + googleCompileSdk: read("packages/google/openiap/build.gradle.kts").match( + /compileSdk\s*=\s*(\d+)/, + )?.[1], + googleMinSdk: read("packages/google/openiap/build.gradle.kts").match( + /minSdk\s*=\s*(\d+)/, + )?.[1], + googlePlayBillingVersion: read( + "packages/google/openiap/build.gradle.kts", + ).match(/val\s+playBillingVersion\s*=\s*"([^"]+)"/)?.[1], + kmpCompileSdk: read("libraries/kmp-iap/gradle/libs.versions.toml").match( + /^android-compileSdk = "([^"]+)"/m, + )?.[1], + kmpMinSdk: read("libraries/kmp-iap/gradle/libs.versions.toml").match( + /^android-minSdk = "([^"]+)"/m, + )?.[1], + kmpTargetSdk: read("libraries/kmp-iap/gradle/libs.versions.toml").match( + /^android-targetSdk = "([^"]+)"/m, + )?.[1], }; - for (const [key, expectedValue] of Object.entries(expectedDocsVersionMetadata)) { + for (const [key, expectedValue] of Object.entries( + expectedDocsVersionMetadata, + )) { if (docsVersionMetadata[key] !== expectedValue) { - fail(`packages/docs/src/generated/version-metadata.json ${key} must be synced from SSOT metadata`); + fail( + `packages/docs/src/generated/version-metadata.json ${key} must be synced from SSOT metadata`, + ); } } } - expectIncludes('packages/apple/README.md', [ - '.package(url: "https://github.com/hyodotdev/openiap.git", from: "")', - "pod 'openiap', '~> '", - 'Use the latest version from the Swift Package / CocoaPods badges above.', - ], 'Apple README install version'); - expectIncludes('packages/google/README.md', [ - 'implementation("io.github.hyochan.openiap:openiap-google:")', - 'https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google', - ], 'Google README install version'); - expectIncludes('packages/docs/src/lib/versioning.ts', [ - "type VersionKey = 'spec' | 'google' | 'apple';", - "'spec'", - "'google'", - "'apple'", - "../generated/version-metadata.json", - 'expoPackageVersion', - 'reactNativePackageVersion', - 'godotPackageVersion', - 'EXPO_PACKAGE_VERSION', - 'REACT_NATIVE_PACKAGE_VERSION', - 'GODOT_PACKAGE_VERSION', - 'dependencyLine: `"expo-iap": "^${EXPO_PACKAGE_VERSION}"`', - ], 'docs versioning keys'); - expectNotIncludes('packages/docs/src/lib/versioning.ts', [ - '../../../../libraries/', - '../../../../packages/', - ], 'docs versioning must not import files outside packages/docs because Vercel uploads the docs package root'); - expectIncludes('packages/docs/src/lib/images.ts', [ - 'EXPO_PACKAGE.installCommand', - 'REACT_NATIVE_PACKAGE.installCommand', - 'GODOT_PACKAGE.releaseUrl', - ], 'docs library metadata must derive framework package versions from metadata'); - expectIncludes('packages/docs/src/pages/docs/setup/expo.tsx', [ - 'EXPO_PACKAGE.dependencyLine', - ], 'Expo docs package.json example must derive package version from package metadata'); - expectNotIncludes('packages/docs/src/pages/docs/setup/expo.tsx', [ - '"expo-iap": "latest"', - ], 'Expo docs package.json example must not use a floating latest version'); - expectIncludes('packages/docs/src/pages/docs/android-setup.tsx', [ - 'OPENIAP_VERSIONS.google', - ], 'Android setup docs install version'); - expectNotIncludes('packages/apple/README.md', [ - '$version', - `from: "${versions.apple}"`, - `pod 'openiap', '~> ${versions.apple}'`, - ], 'Apple README install version must not be inline hardcoded'); - expectNotIncludes('packages/google/README.md', [ - '$version', - `openiap-google:${versions.google}`, - ], 'Google README install version must not be inline hardcoded'); + expectIncludes( + "packages/apple/README.md", + [ + '.package(url: "https://github.com/hyodotdev/openiap.git", from: "")', + "pod 'openiap', '~> '", + "Use the latest version from the Swift Package / CocoaPods badges above.", + ], + "Apple README install version", + ); + expectIncludes( + "packages/google/README.md", + [ + 'implementation("io.github.hyochan.openiap:openiap-google:")', + "https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google", + ], + "Google README install version", + ); + expectIncludes( + "packages/docs/src/lib/versioning.ts", + [ + "type VersionKey = 'spec' | 'google' | 'apple';", + "'spec'", + "'google'", + "'apple'", + "../generated/version-metadata.json", + "expoPackageVersion", + "reactNativePackageVersion", + "godotPackageVersion", + "EXPO_PACKAGE_VERSION", + "REACT_NATIVE_PACKAGE_VERSION", + "GODOT_PACKAGE_VERSION", + 'dependencyLine: `"expo-iap": "^${EXPO_PACKAGE_VERSION}"`', + ], + "docs versioning keys", + ); + expectNotIncludes( + "packages/docs/src/lib/versioning.ts", + ["../../../../libraries/", "../../../../packages/"], + "docs versioning must not import files outside packages/docs because Vercel uploads the docs package root", + ); + expectIncludes( + "packages/docs/src/lib/images.ts", + [ + "EXPO_PACKAGE.installCommand", + "REACT_NATIVE_PACKAGE.installCommand", + "GODOT_PACKAGE.releaseUrl", + ], + "docs library metadata must derive framework package versions from metadata", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/expo.tsx", + ["EXPO_PACKAGE.dependencyLine"], + "Expo docs package.json example must derive package version from package metadata", + ); + expectNotIncludes( + "packages/docs/src/pages/docs/setup/expo.tsx", + ['"expo-iap": "latest"'], + "Expo docs package.json example must not use a floating latest version", + ); + expectIncludes( + "packages/docs/src/pages/docs/android-setup.tsx", + ["OPENIAP_VERSIONS.google"], + "Android setup docs install version", + ); + expectNotIncludes( + "packages/apple/README.md", + [ + "$version", + `from: "${versions.apple}"`, + `pod 'openiap', '~> ${versions.apple}'`, + ], + "Apple README install version must not be inline hardcoded", + ); + expectNotIncludes( + "packages/google/README.md", + ["$version", `openiap-google:${versions.google}`], + "Google README install version must not be inline hardcoded", + ); for (const localPathFile of [ - 'knowledge/internal/07-docs-consistency.md', - 'libraries/kmp-iap/example/run-ios.sh', + "knowledge/internal/07-docs-consistency.md", + "libraries/kmp-iap/example/run-ios.sh", ]) { - expectNotIncludes(localPathFile, [ - '/Users/', - '/home/', - 'C:\\', - ], 'tracked scripts/docs must not contain local absolute paths'); - } - expectOptionalNotIncludes('libraries/expo-iap/example/android/settings.gradle', [ - '/Users/', - '/home/', - 'C:\\', - ], 'tracked scripts/docs must not contain local absolute paths'); + expectNotIncludes( + localPathFile, + ["/Users/", "/home/", "C:\\"], + "tracked scripts/docs must not contain local absolute paths", + ); + } + expectOptionalNotIncludes( + "libraries/expo-iap/example/android/settings.gradle", + ["/Users/", "/home/", "C:\\"], + "tracked scripts/docs must not contain local absolute paths", + ); for (const canonicalUrlFile of [ - 'scripts/audit-docs.ts', - 'libraries/expo-iap/CLAUDE.md', - 'libraries/flutter_inapp_purchase/CLAUDE.md', - 'libraries/kmp-iap/CLAUDE.md', - 'libraries/react-native-iap/CLAUDE.md', - 'libraries/react-native-iap/src/index.ts', - 'libraries/react-native-iap/src/hooks/useIAP.ts', - 'libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart', - 'libraries/godot-iap/addons/godot-iap/godot_iap.gd', - 'libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt', - 'libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt', - 'packages/apple/Sources/OpenIapModule.swift', - 'packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt', + "scripts/audit-docs.ts", + "libraries/expo-iap/CLAUDE.md", + "libraries/flutter_inapp_purchase/CLAUDE.md", + "libraries/kmp-iap/CLAUDE.md", + "libraries/react-native-iap/CLAUDE.md", + "libraries/react-native-iap/src/index.ts", + "libraries/react-native-iap/src/hooks/useIAP.ts", + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + "libraries/godot-iap/addons/godot-iap/godot_iap.gd", + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt", + "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", + "packages/apple/Sources/OpenIapModule.swift", + "packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt", ]) { - expectNotIncludes(canonicalUrlFile, [ - 'https://www.openiap.dev', - ], `${canonicalUrlFile} must use canonical OpenIAP URLs`); - } - expectIncludes('packages/docs/src/lib/images.ts', [ - "documentationUrl: 'https://openiap.dev/docs/setup/expo'", - "documentationUrl: 'https://openiap.dev/docs/setup/react-native'", - "documentationUrl: 'https://openiap.dev/docs/setup/flutter'", - "documentationUrl: 'https://openiap.dev/docs/setup/kmp'", - "documentationUrl: 'https://openiap.dev/docs/setup/godot'", - ], 'docs library cards should point at OpenIAP setup docs'); - expectNotIncludes('packages/docs/src/lib/images.ts', [ - "documentationUrl: 'https://hyochan.github.io/expo-iap'", - "documentationUrl: 'https://hyochan.github.io/react-native-iap'", - "documentationUrl: 'https://hyochan.github.io/flutter_inapp_purchase'", - "documentationUrl: 'https://hyochan.github.io/kmp-iap'", - "documentationUrl: 'https://hyochan.github.io/godot-iap'", - ], 'docs library cards must not point at legacy standalone docs'); - expectIncludes('CHANGELOG.md', [ - 'https://openiap.dev/docs/updates/releases', - 'https://github.com/hyodotdev/openiap/releases', - ], 'root changelog should point at OpenIAP release SSOT'); - expectNotIncludes('CHANGELOG.md', [ - 'https://www.openiap.dev/docs/updates/notes', - ], 'root changelog must not point at redirected release notes URL'); - expectIncludes('libraries/expo-iap/README.md', [ - 'https://openiap.dev/frameworks/expo.svg', - 'https://openiap.dev/docs/setup/expo', - 'https://openiap.dev/docs/guides/ai-assistants', - 'https://openiap.dev/llms.txt', - 'https://openiap.dev/docs/setup/store/onside', - 'https://github.com/hyodotdev/openiap/discussions/categories/expo-iap', - 'https://openiap.dev/sponsors/nami.webp', - 'https://openiap.dev/sponsors/courier.webp', - ], 'Expo README docs links'); - expectIncludes('libraries/expo-iap/CONTRIBUTING.md', [ - 'https://openiap.dev/docs/setup/expo', - 'https://openiap.dev/docs/apis', - ], 'Expo contributing docs links'); - expectIncludes('libraries/expo-iap/src/index.ts', [ - 'https://openiap.dev/docs/apis/request-purchase', - ], 'Expo runtime docs links'); - expectIncludes('libraries/expo-iap/src/useIAP.ts', [ - 'https://openiap.dev/docs/setup/expo#useIAP-hook', - 'https://openiap.dev/docs/apis/request-purchase', - ], 'Expo useIAP docs links'); - expectNotIncludes('libraries/expo-iap/README.md', [ - '](https://hyochan.github.io/expo-iap', - 'https://hyochan.github.io/expo-iap/guides', - 'https://hyochan.github.io/expo-iap/llms', - 'https://hyochan.github.io/expo-iap/getting-started', - 'https://github.com/hyochan/expo-iap/discussions/143', - 'https://github.com/hyochan/react-native-iap/discussions/2754', - 'https://github.com/hyochan/react-native-iap/assets', - 'https://github.com/user-attachments/assets/319d8966-6839-498d-8ead-ce8cc72c3bca', - 'https://www.openiap.dev', - ], 'Expo README must not point at legacy standalone docs'); - expectIncludes('libraries/expo-iap/CHANGELOG.md', [ - 'https://openiap.dev/docs/updates/releases', - 'https://github.com/hyodotdev/openiap/releases?q=expo-iap&expanded=true', - ], 'Expo changelog release SSOT links'); - expectNotIncludes('libraries/expo-iap/CHANGELOG.md', [ - 'github.com/hyochan/expo-iap', - ], 'Expo changelog must not point at the legacy standalone repository'); + expectNotIncludes( + canonicalUrlFile, + ["https://www.openiap.dev"], + `${canonicalUrlFile} must use canonical OpenIAP URLs`, + ); + } + expectIncludes( + "packages/docs/src/lib/images.ts", + [ + "documentationUrl: 'https://openiap.dev/docs/setup/expo'", + "documentationUrl: 'https://openiap.dev/docs/setup/react-native'", + "documentationUrl: 'https://openiap.dev/docs/setup/flutter'", + "documentationUrl: 'https://openiap.dev/docs/setup/kmp'", + "documentationUrl: 'https://openiap.dev/docs/setup/godot'", + ], + "docs library cards should point at OpenIAP setup docs", + ); + expectNotIncludes( + "packages/docs/src/lib/images.ts", + [ + "documentationUrl: 'https://hyochan.github.io/expo-iap'", + "documentationUrl: 'https://hyochan.github.io/react-native-iap'", + "documentationUrl: 'https://hyochan.github.io/flutter_inapp_purchase'", + "documentationUrl: 'https://hyochan.github.io/kmp-iap'", + "documentationUrl: 'https://hyochan.github.io/godot-iap'", + ], + "docs library cards must not point at legacy standalone docs", + ); + expectIncludes( + "CHANGELOG.md", + [ + "https://openiap.dev/docs/updates/releases", + "https://github.com/hyodotdev/openiap/releases", + ], + "root changelog should point at OpenIAP release SSOT", + ); + expectNotIncludes( + "CHANGELOG.md", + ["https://www.openiap.dev/docs/updates/notes"], + "root changelog must not point at redirected release notes URL", + ); + expectIncludes( + "libraries/expo-iap/README.md", + [ + "https://openiap.dev/frameworks/expo.svg", + "https://openiap.dev/docs/setup/expo", + "https://openiap.dev/docs/guides/ai-assistants", + "https://openiap.dev/llms.txt", + "https://openiap.dev/docs/setup/store/onside", + "https://github.com/hyodotdev/openiap/discussions/categories/expo-iap", + "https://openiap.dev/sponsors/nami.webp", + "https://openiap.dev/sponsors/courier.webp", + ], + "Expo README docs links", + ); + expectIncludes( + "libraries/expo-iap/CONTRIBUTING.md", + ["https://openiap.dev/docs/setup/expo", "https://openiap.dev/docs/apis"], + "Expo contributing docs links", + ); + expectIncludes( + "libraries/expo-iap/src/index.ts", + ["https://openiap.dev/docs/apis/request-purchase"], + "Expo runtime docs links", + ); + expectIncludes( + "libraries/expo-iap/src/useIAP.ts", + [ + "https://openiap.dev/docs/setup/expo#useIAP-hook", + "https://openiap.dev/docs/apis/request-purchase", + ], + "Expo useIAP docs links", + ); + expectNotIncludes( + "libraries/expo-iap/README.md", + [ + "](https://hyochan.github.io/expo-iap", + "https://hyochan.github.io/expo-iap/guides", + "https://hyochan.github.io/expo-iap/llms", + "https://hyochan.github.io/expo-iap/getting-started", + "https://github.com/hyochan/expo-iap/discussions/143", + "https://github.com/hyochan/react-native-iap/discussions/2754", + "https://github.com/hyochan/react-native-iap/assets", + "https://github.com/user-attachments/assets/319d8966-6839-498d-8ead-ce8cc72c3bca", + "https://www.openiap.dev", + ], + "Expo README must not point at legacy standalone docs", + ); + expectIncludes( + "libraries/expo-iap/CHANGELOG.md", + [ + "https://openiap.dev/docs/updates/releases", + "https://github.com/hyodotdev/openiap/releases?q=expo-iap&expanded=true", + ], + "Expo changelog release SSOT links", + ); + expectNotIncludes( + "libraries/expo-iap/CHANGELOG.md", + ["github.com/hyochan/expo-iap"], + "Expo changelog must not point at the legacy standalone repository", + ); for (const expoDocsFile of [ - 'libraries/expo-iap/CONTRIBUTING.md', - 'libraries/expo-iap/src/index.ts', - 'libraries/expo-iap/src/useIAP.ts', - 'libraries/expo-iap/src/modules/android.ts', - 'libraries/expo-iap/src/modules/ios.ts', + "libraries/expo-iap/CONTRIBUTING.md", + "libraries/expo-iap/src/index.ts", + "libraries/expo-iap/src/useIAP.ts", + "libraries/expo-iap/src/modules/android.ts", + "libraries/expo-iap/src/modules/ios.ts", ]) { - expectNotIncludes(expoDocsFile, [ - 'hyochan.github.io/expo-iap', - 'https://www.openiap.dev', - ], `${expoDocsFile} must not point at legacy standalone docs`); - } - expectIncludes('libraries/react-native-iap/README.md', [ - 'https://openiap.dev/frameworks/react-native.webp', - 'https://openiap.dev/docs/setup/react-native', - 'https://openiap.dev/docs/guides/ai-assistants', - 'https://openiap.dev/llms.txt', - 'https://openiap.dev/docs/apis', - 'https://openiap.dev/docs/errors', - 'https://openiap.dev/sponsors/nami.webp', - 'https://openiap.dev/sponsors/courier.webp', - ], 'React Native README docs links'); - expectIncludes('libraries/react-native-iap/CONTRIBUTING.md', [ - 'https://openiap.dev/docs/setup/react-native', - ], 'React Native contributing docs links'); - expectNotIncludes('libraries/react-native-iap/README.md', [ - '](https://hyochan.github.io/react-native-iap', - 'https://hyochan.github.io/react-native-iap/docs', - 'https://hyochan.github.io/react-native-iap/llms', - 'https://github.com/hyochan/react-native-iap/assets', - 'https://github.com/user-attachments/assets/319d8966-6839-498d-8ead-ce8cc72c3bca', - 'https://www.openiap.dev', - ], 'React Native README must not point at legacy standalone docs'); - expectIncludes('libraries/react-native-iap/CHANGELOG.md', [ - 'https://openiap.dev/docs/updates/releases', - 'https://github.com/hyodotdev/openiap/releases?q=react-native-iap&expanded=true', - ], 'React Native changelog release SSOT links'); - expectNotIncludes('libraries/react-native-iap/CHANGELOG.md', [ - 'github.com/hyochan/react-native-iap', - ], 'React Native changelog must not point at the legacy standalone repository'); - expectNotIncludes('libraries/react-native-iap/CONTRIBUTING.md', [ - 'hyochan.github.io/react-native-iap', - 'Recent highlights (', - 'OpenIAP to `~>', - ], 'React Native contributing must not point at legacy standalone docs'); - expectIncludes('libraries/react-native-iap/scripts/ci-check.sh', [ - 'run_check()', - 'run_check "📦 Installing dependencies..."', - 'run_check "🧪 Running tests..."', - ], 'React Native local CI script should use shared check helper'); - expectNotIncludes('libraries/react-native-iap/scripts/ci-check.sh', [ - 'if [ $? -ne 0 ]; then', - ], 'React Native local CI script should not repeat exit-code checks'); - expectIncludes('libraries/flutter_inapp_purchase/README.md', [ - 'https://openiap.dev/frameworks/flutter.svg', - 'https://openiap.dev/docs/setup/flutter', - 'https://openiap.dev/docs/guides/ai-assistants', - 'https://openiap.dev/llms.txt', - ], 'Flutter README docs links'); - expectIncludes('libraries/flutter_inapp_purchase/CONTRIBUTING.md', [ - 'https://github.com/hyodotdev/openiap/discussions', - ], 'Flutter contributing discussion links'); - expectNotIncludes('libraries/flutter_inapp_purchase/README.md', [ - '](https://hyochan.github.io/flutter_inapp_purchase', - 'https://hyochan.github.io/flutter_inapp_purchase/docs', - 'https://hyochan.github.io/flutter_inapp_purchase/llms', - ], 'Flutter README must not point at legacy standalone docs'); - expectNotIncludes('libraries/flutter_inapp_purchase/CONTRIBUTING.md', [ - 'github.com/hyochan/openiap.dev', - ], 'Flutter contributing links must point at the monorepo'); - expectIncludes('libraries/flutter_inapp_purchase/CHANGELOG.md', [ - 'https://openiap.dev/docs/updates/releases', - 'https://github.com/hyodotdev/openiap/releases?q=flutter-iap&expanded=true', - ], 'Flutter changelog release SSOT links'); + expectNotIncludes( + expoDocsFile, + ["hyochan.github.io/expo-iap", "https://www.openiap.dev"], + `${expoDocsFile} must not point at legacy standalone docs`, + ); + } + expectIncludes( + "libraries/react-native-iap/README.md", + [ + "https://openiap.dev/frameworks/react-native.webp", + "https://openiap.dev/docs/setup/react-native", + "https://openiap.dev/docs/guides/ai-assistants", + "https://openiap.dev/llms.txt", + "https://openiap.dev/docs/apis", + "https://openiap.dev/docs/errors", + "https://openiap.dev/sponsors/nami.webp", + "https://openiap.dev/sponsors/courier.webp", + ], + "React Native README docs links", + ); + expectIncludes( + "libraries/react-native-iap/CONTRIBUTING.md", + ["https://openiap.dev/docs/setup/react-native"], + "React Native contributing docs links", + ); + expectNotIncludes( + "libraries/react-native-iap/README.md", + [ + "](https://hyochan.github.io/react-native-iap", + "https://hyochan.github.io/react-native-iap/docs", + "https://hyochan.github.io/react-native-iap/llms", + "https://github.com/hyochan/react-native-iap/assets", + "https://github.com/user-attachments/assets/319d8966-6839-498d-8ead-ce8cc72c3bca", + "https://www.openiap.dev", + ], + "React Native README must not point at legacy standalone docs", + ); + expectIncludes( + "libraries/react-native-iap/CHANGELOG.md", + [ + "https://openiap.dev/docs/updates/releases", + "https://github.com/hyodotdev/openiap/releases?q=react-native-iap&expanded=true", + ], + "React Native changelog release SSOT links", + ); + expectNotIncludes( + "libraries/react-native-iap/CHANGELOG.md", + ["github.com/hyochan/react-native-iap"], + "React Native changelog must not point at the legacy standalone repository", + ); + expectNotIncludes( + "libraries/react-native-iap/CONTRIBUTING.md", + [ + "hyochan.github.io/react-native-iap", + "Recent highlights (", + "OpenIAP to `~>", + ], + "React Native contributing must not point at legacy standalone docs", + ); + expectIncludes( + "libraries/react-native-iap/scripts/ci-check.sh", + [ + "run_check()", + 'run_check "📦 Installing dependencies..."', + 'run_check "🧪 Running tests..."', + ], + "React Native local CI script should use shared check helper", + ); + expectNotIncludes( + "libraries/react-native-iap/scripts/ci-check.sh", + ["if [ $? -ne 0 ]; then"], + "React Native local CI script should not repeat exit-code checks", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/README.md", + [ + "https://openiap.dev/frameworks/flutter.svg", + "https://openiap.dev/docs/setup/flutter", + "https://openiap.dev/docs/guides/ai-assistants", + "https://openiap.dev/llms.txt", + ], + "Flutter README docs links", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/CONTRIBUTING.md", + ["https://github.com/hyodotdev/openiap/discussions"], + "Flutter contributing discussion links", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/README.md", + [ + "](https://hyochan.github.io/flutter_inapp_purchase", + "https://hyochan.github.io/flutter_inapp_purchase/docs", + "https://hyochan.github.io/flutter_inapp_purchase/llms", + ], + "Flutter README must not point at legacy standalone docs", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/CONTRIBUTING.md", + ["github.com/hyochan/openiap.dev"], + "Flutter contributing links must point at the monorepo", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/CHANGELOG.md", + [ + "https://openiap.dev/docs/updates/releases", + "https://github.com/hyodotdev/openiap/releases?q=flutter-iap&expanded=true", + ], + "Flutter changelog release SSOT links", + ); expectNoMatch( - 'libraries/flutter_inapp_purchase/CHANGELOG.md', + "libraries/flutter_inapp_purchase/CHANGELOG.md", /^## 9\.[^\r\n]*\r?\n\r?\n- Initial release\r?$/m, - 'Flutter v9 changelog entries must not be placeholders', - ); - expectIncludes('.github/workflows/release-flutter.yml', [ - 'flutter-iap-$PREV_VERSION', - 'CONSOLIDATED_RELEASE_NOTES="https://openiap.dev/docs/updates/releases"', - ], 'Flutter release workflow should generate changelog entries from prefixed tags'); - expectIncludes('libraries/godot-iap/README.md', [ - 'https://openiap.dev/docs/setup/godot', - 'https://openiap.dev/docs/guides/ai-assistants', - 'https://openiap.dev/llms.txt', - 'https://openiap.dev/docs/apis', - 'https://openiap.dev/docs/example', - '[LICENSE](../../LICENSE)', - ], 'Godot README docs links'); - expectIncludes('libraries/godot-iap/CONTRIBUTING.md', [ - 'https://github.com/hyodotdev/openiap/issues', - 'https://openiap.dev/docs/setup/godot', - ], 'Godot contributing docs links'); - expectIncludes('libraries/godot-iap/EXAMPLES.md', [ - 'https://openiap.dev/docs/setup/godot', - 'https://openiap.dev/docs/apis', - ], 'Godot examples docs links'); + "Flutter v9 changelog entries must not be placeholders", + ); + expectIncludes( + ".github/workflows/release-flutter.yml", + [ + "flutter-iap-$PREV_VERSION", + 'CONSOLIDATED_RELEASE_NOTES="https://openiap.dev/docs/updates/releases"', + ], + "Flutter release workflow should generate changelog entries from prefixed tags", + ); + expectIncludes( + "libraries/godot-iap/README.md", + [ + "https://openiap.dev/docs/setup/godot", + "https://openiap.dev/docs/guides/ai-assistants", + "https://openiap.dev/llms.txt", + "https://openiap.dev/docs/apis", + "https://openiap.dev/docs/example", + "[LICENSE](../../LICENSE)", + ], + "Godot README docs links", + ); + expectIncludes( + "libraries/godot-iap/CONTRIBUTING.md", + [ + "https://github.com/hyodotdev/openiap/issues", + "https://openiap.dev/docs/setup/godot", + ], + "Godot contributing docs links", + ); + expectIncludes( + "libraries/godot-iap/EXAMPLES.md", + ["https://openiap.dev/docs/setup/godot", "https://openiap.dev/docs/apis"], + "Godot examples docs links", + ); for (const godotDocsFile of [ - 'libraries/godot-iap/README.md', - 'libraries/godot-iap/CONTRIBUTING.md', - 'libraries/godot-iap/EXAMPLES.md', + "libraries/godot-iap/README.md", + "libraries/godot-iap/CONTRIBUTING.md", + "libraries/godot-iap/EXAMPLES.md", ]) { - expectNotIncludes(godotDocsFile, [ - 'hyochan.github.io/godot-iap', - 'github.com/hyochan/godot-iap', - '[LICENSE](LICENSE)', - ], `${godotDocsFile} must not point at legacy standalone docs`); + expectNotIncludes( + godotDocsFile, + [ + "hyochan.github.io/godot-iap", + "github.com/hyochan/godot-iap", + "[LICENSE](LICENSE)", + ], + `${godotDocsFile} must not point at legacy standalone docs`, + ); } - const kmpVersion = read('libraries/kmp-iap/gradle.properties') + const kmpVersion = read("libraries/kmp-iap/gradle.properties") .match(/^libraryVersion=(.+)$/m)?.[1] ?.trim(); - const kmpLibraryVersions = read('libraries/kmp-iap/gradle/libs.versions.toml'); - const kmpKotlinVersion = kmpLibraryVersions.match(/^kotlin = "([^"]+)"/m)?.[1]; - const kmpGradleVersion = read('libraries/kmp-iap/gradle/wrapper/gradle-wrapper.properties') - .match(/gradle-([^-]+)-bin\.zip/)?.[1]; - const flutterVersion = read('libraries/flutter_inapp_purchase/pubspec.yaml') + const kmpLibraryVersions = read( + "libraries/kmp-iap/gradle/libs.versions.toml", + ); + const kmpKotlinVersion = + kmpLibraryVersions.match(/^kotlin = "([^"]+)"/m)?.[1]; + const kmpGradleVersion = read( + "libraries/kmp-iap/gradle/wrapper/gradle-wrapper.properties", + ).match(/gradle-([^-]+)-bin\.zip/)?.[1]; + const flutterVersion = read("libraries/flutter_inapp_purchase/pubspec.yaml") .match(/^version:\s*(.+)$/m)?.[1] ?.trim(); - const mauiVersion = read('libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj') + const mauiVersion = read( + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + ) .match(/([^<]+)<\/PackageVersion>/)?.[1] ?.trim(); - if (!kmpVersion) fail('libraries/kmp-iap/gradle.properties is missing libraryVersion'); - if (!flutterVersion) fail('libraries/flutter_inapp_purchase/pubspec.yaml is missing version'); - if (!mauiVersion) fail('OpenIap.Maui.csproj is missing PackageVersion'); + if (!kmpVersion) + fail("libraries/kmp-iap/gradle.properties is missing libraryVersion"); + if (!flutterVersion) + fail("libraries/flutter_inapp_purchase/pubspec.yaml is missing version"); + if (!mauiVersion) fail("OpenIap.Maui.csproj is missing PackageVersion"); if (flutterVersion) { - expectIncludes('packages/docs/src/lib/versioning.ts', [ - 'flutterPackageVersion', - 'FLUTTER_PACKAGE', - 'dependencyLine: `flutter_inapp_purchase: ^${FLUTTER_PACKAGE_VERSION}`', - ], 'docs Flutter package metadata must derive from generated docs metadata'); - expectIncludes('packages/docs/src/lib/images.ts', [ - 'FLUTTER_PACKAGE.installCommand', - ], 'docs Flutter library listing install command'); - expectIncludes('packages/docs/src/pages/docs/setup/flutter.tsx', [ - 'ANDROID_SDK', - 'FLUTTER_PACKAGE', - 'FLUTTER_PACKAGE.dependencyLine', - ], 'Flutter setup docs package metadata'); - expectIncludes('libraries/flutter_inapp_purchase/README.md', [ - 'flutter pub add flutter_inapp_purchase', - 'https://pub.dev/packages/flutter_inapp_purchase', - ], 'Flutter README should reference pub.dev without an inline version'); + expectIncludes( + "packages/docs/src/lib/versioning.ts", + [ + "flutterPackageVersion", + "FLUTTER_PACKAGE", + "dependencyLine: `flutter_inapp_purchase: ^${FLUTTER_PACKAGE_VERSION}`", + ], + "docs Flutter package metadata must derive from generated docs metadata", + ); + expectIncludes( + "packages/docs/src/lib/images.ts", + ["FLUTTER_PACKAGE.installCommand"], + "docs Flutter library listing install command", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/flutter.tsx", + ["ANDROID_SDK", "FLUTTER_PACKAGE", "FLUTTER_PACKAGE.dependencyLine"], + "Flutter setup docs package metadata", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/README.md", + [ + "flutter pub add flutter_inapp_purchase", + "https://pub.dev/packages/flutter_inapp_purchase", + ], + "Flutter README should reference pub.dev without an inline version", + ); for (const flutterDoc of [ - 'libraries/flutter_inapp_purchase/README.md', - 'packages/docs/src/pages/docs/setup/flutter.tsx', + "libraries/flutter_inapp_purchase/README.md", + "packages/docs/src/pages/docs/setup/flutter.tsx", ]) { - expectNotIncludes(flutterDoc, [ - `flutter_inapp_purchase: ^${flutterVersion}`, - ], 'Flutter install command version must not be inline hardcoded'); + expectNotIncludes( + flutterDoc, + [`flutter_inapp_purchase: ^${flutterVersion}`], + "Flutter install command version must not be inline hardcoded", + ); } for (const flutterPodspec of [ - 'libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase.podspec', - 'libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase.podspec', + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase.podspec", + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase.podspec", ]) { - expectIncludes(flutterPodspec, [ - 'pubspec_path', - 'pubspec_version', - 's.version = pubspec_version', - ], 'Flutter podspec version must derive from pubspec.yaml'); - expectNotIncludes(flutterPodspec, [ - "s.version = '0.0.1'", - ], 'Flutter podspec version must not drift from pubspec.yaml'); + expectIncludes( + flutterPodspec, + [ + "pubspec_path", + "pubspec_version", + "s.version = pubspec_version", + ], + "Flutter podspec version must derive from pubspec.yaml", + ); + expectNotIncludes( + flutterPodspec, + ["s.version = '0.0.1'"], + "Flutter podspec version must not drift from pubspec.yaml", + ); } - expectNotIncludes('libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase.podspec', [ - 'http://example.com', - 'Your Company', - 'email@example.com', - ], 'Flutter iOS podspec metadata must not use placeholders'); + expectNotIncludes( + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase.podspec", + ["http://example.com", "Your Company", "email@example.com"], + "Flutter iOS podspec metadata must not use placeholders", + ); } if (kmpVersion) { - expectIncludes('packages/docs/src/lib/versioning.ts', [ - 'kmpPackageVersion', - 'KMP_PACKAGE', - 'installCommand: `implementation("io.github.hyochan:kmp-iap:${KMP_PACKAGE_VERSION}")`', - ], 'docs KMP package metadata must derive from generated docs metadata'); - expectIncludes('packages/docs/src/lib/images.ts', [ - 'KMP_PACKAGE.installCommand', - ], 'docs KMP install command'); - expectIncludes('packages/docs/src/pages/docs/setup/kmp.tsx', [ - "LIBRARIES.find(({ name }) => name === 'kmp-iap')", - 'KMP_ANDROID_SDK', - 'KMP_INSTALL_COMMAND', - 'KMP_VERSION', - ], 'KMP setup docs install command'); - expectNotIncludes('packages/docs/src/pages/docs/setup/kmp.tsx', [ - '', - ], 'KMP setup docs install command must be copyable'); - expectIncludes('libraries/kmp-iap/README.md', [ - 'implementation("io.github.hyochan:kmp-iap:")', - 'https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap', - 'https://openiap.dev/docs/setup/kmp', - 'https://openiap.dev/llms.txt', - 'license-Apache--2.0', - 'Apache License 2.0', - ], 'KMP README should reference Maven Central without an inline version'); - expectNotIncludes('libraries/kmp-iap/README.md', [ - `implementation("io.github.hyochan:kmp-iap:${kmpVersion}")`, - 'hyochan.github.io/kmp-iap', - 'license-MIT', - 'MIT License', - ], 'KMP README install command version must not be inline hardcoded'); - expectIncludes('libraries/kmp-iap/library/library.podspec', [ - 'gradle_properties_file', - 'libraryVersion=', - 'spec.version = library_version', - 'spec.source = { :git => \'https://github.com/hyodotdev/openiap.git\', :tag => "kmp-iap-#{library_version}" }', - "spec.authors = { 'Hyo Dev' => 'hyo@hyo.dev' }", - "spec.license = { :type => 'Apache-2.0', :file => '../LICENSE' }", - ], 'KMP podspec version'); - expectNotIncludes('libraries/kmp-iap/library/library.podspec', [ - `spec.version = '${kmpVersion}'`, - "spec.source = { :http=> ''}", - "spec.authors = ''", - "spec.license = ''", - ], 'KMP podspec version must derive from gradle.properties'); - expectIncludes('libraries/kmp-iap/library/build.gradle.kts', [ - 'fun dynamicKmpPodspec(): String', - 'tasks.matching { it.name == "podspec" }.configureEach', - 'projectDir.resolve("library.podspec").writeText(dynamicKmpPodspec())', - 'spec.version = library_version', - 'openiap_apple_version = openiap_versions[\'apple\']', - ], 'KMP podspec Gradle generator must preserve dynamic metadata'); - } - const kmpExampleVersions = read('libraries/kmp-iap/example/gradle/libs.versions.toml'); - const kmpExampleKotlinVersion = kmpExampleVersions.match(/^kotlin = "([^"]+)"/m)?.[1]; - const kmpCompileSdk = kmpExampleVersions.match(/^android-compileSdk = "([^"]+)"/m)?.[1]; - const kmpMinSdk = kmpExampleVersions.match(/^android-minSdk = "([^"]+)"/m)?.[1]; - const kmpTargetSdk = kmpExampleVersions.match(/^android-targetSdk = "([^"]+)"/m)?.[1]; + expectIncludes( + "packages/docs/src/lib/versioning.ts", + [ + "kmpPackageVersion", + "KMP_PACKAGE", + 'installCommand: `implementation("io.github.hyochan:kmp-iap:${KMP_PACKAGE_VERSION}")`', + ], + "docs KMP package metadata must derive from generated docs metadata", + ); + expectIncludes( + "packages/docs/src/lib/images.ts", + ["KMP_PACKAGE.installCommand"], + "docs KMP install command", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/kmp.tsx", + [ + "LIBRARIES.find(({ name }) => name === 'kmp-iap')", + "KMP_ANDROID_SDK", + "KMP_INSTALL_COMMAND", + "KMP_VERSION", + ], + "KMP setup docs install command", + ); + expectNotIncludes( + "packages/docs/src/pages/docs/setup/kmp.tsx", + [""], + "KMP setup docs install command must be copyable", + ); + expectIncludes( + "libraries/kmp-iap/README.md", + [ + 'implementation("io.github.hyochan:kmp-iap:")', + "https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap", + "https://openiap.dev/docs/setup/kmp", + "https://openiap.dev/llms.txt", + "license-Apache--2.0", + "Apache License 2.0", + ], + "KMP README should reference Maven Central without an inline version", + ); + expectNotIncludes( + "libraries/kmp-iap/README.md", + [ + `implementation("io.github.hyochan:kmp-iap:${kmpVersion}")`, + "hyochan.github.io/kmp-iap", + "license-MIT", + "MIT License", + ], + "KMP README install command version must not be inline hardcoded", + ); + expectIncludes( + "libraries/kmp-iap/library/library.podspec", + [ + "gradle_properties_file", + "libraryVersion=", + "spec.version = library_version", + "spec.source = { :git => 'https://github.com/hyodotdev/openiap.git', :tag => \"kmp-iap-#{library_version}\" }", + "spec.authors = { 'Hyo Dev' => 'hyo@hyo.dev' }", + "spec.license = { :type => 'Apache-2.0', :file => '../LICENSE' }", + ], + "KMP podspec version", + ); + expectNotIncludes( + "libraries/kmp-iap/library/library.podspec", + [ + `spec.version = '${kmpVersion}'`, + "spec.source = { :http=> ''}", + "spec.authors = ''", + "spec.license = ''", + ], + "KMP podspec version must derive from gradle.properties", + ); + expectIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + [ + "fun dynamicKmpPodspec(): String", + 'tasks.matching { it.name == "podspec" }.configureEach', + 'projectDir.resolve("library.podspec").writeText(dynamicKmpPodspec())', + "spec.version = library_version", + "openiap_apple_version = openiap_versions['apple']", + ], + "KMP podspec Gradle generator must preserve dynamic metadata", + ); + } + const kmpExampleVersions = read( + "libraries/kmp-iap/example/gradle/libs.versions.toml", + ); + const kmpExampleKotlinVersion = + kmpExampleVersions.match(/^kotlin = "([^"]+)"/m)?.[1]; + const kmpCompileSdk = kmpExampleVersions.match( + /^android-compileSdk = "([^"]+)"/m, + )?.[1]; + const kmpMinSdk = kmpExampleVersions.match( + /^android-minSdk = "([^"]+)"/m, + )?.[1]; + const kmpTargetSdk = kmpExampleVersions.match( + /^android-targetSdk = "([^"]+)"/m, + )?.[1]; if (!kmpKotlinVersion || !kmpGradleVersion) { - fail('KMP library must declare Kotlin and Gradle wrapper versions'); + fail("KMP library must declare Kotlin and Gradle wrapper versions"); } else if (kmpExampleKotlinVersion !== kmpKotlinVersion) { - fail(`KMP example Kotlin ${kmpExampleKotlinVersion} must match library Kotlin ${kmpKotlinVersion}`); + fail( + `KMP example Kotlin ${kmpExampleKotlinVersion} must match library Kotlin ${kmpKotlinVersion}`, + ); } else { - expectIncludes('packages/docs/src/pages/docs/setup/kmp.tsx', [ - `Kotlin ${kmpKotlinVersion}+`, - `Gradle ${kmpGradleVersion}+`, - 'JDK 17+', - ], 'KMP setup docs toolchain versions'); - expectIncludes('libraries/kmp-iap/example/composeApp/build.gradle.kts', [ - 'implementation(libs.kotlinx.coroutines.swing)', - ], 'KMP example desktop coroutines must use version catalog'); - expectIncludes('libraries/kmp-iap/example/gradle/libs.versions.toml', [ - 'kotlinx-coroutines-swing', - ], 'KMP example coroutines aliases must match root catalog naming'); - expectNotIncludes('libraries/kmp-iap/example/gradle/libs.versions.toml', [ - 'kotlinx-coroutinesSwing', - ], 'KMP example coroutines aliases must not use root-incompatible camel suffixes'); - expectNotIncludes('libraries/kmp-iap/example/composeApp/build.gradle.kts', [ - 'org.jetbrains.kotlinx:kotlinx-coroutines-swing:', - ], 'KMP example desktop coroutines must not hardcode Maven versions'); - expectIncludes('libraries/kmp-iap/CONTRIBUTING.md', [ - 'JDK 17 or higher', - 'https://github.com/hyodotdev/openiap/issues', - 'https://github.com/hyodotdev/openiap/discussions/categories/kmp-iap', - ], 'KMP contributing JDK requirement'); - expectNotIncludes('libraries/kmp-iap/CONTRIBUTING.md', [ - 'github.com/hyochan/kmp-iap', - 'github.com/hyochan/openiap.dev', - ], 'KMP contributing links must point at the monorepo'); - expectIncludes('libraries/kmp-iap/CHANGELOG.md', [ - 'https://github.com/hyodotdev/openiap/releases?q=kmp-iap&expanded=true', - ], 'KMP changelog release link'); - expectNotIncludes('libraries/kmp-iap/CHANGELOG.md', [ - 'github.com/hyochan/kmp-iap', - ], 'KMP changelog must not point at the legacy standalone repository'); + expectIncludes( + "packages/docs/src/pages/docs/setup/kmp.tsx", + [`Kotlin ${kmpKotlinVersion}+`, `Gradle ${kmpGradleVersion}+`, "JDK 17+"], + "KMP setup docs toolchain versions", + ); + expectIncludes( + "libraries/kmp-iap/example/composeApp/build.gradle.kts", + ["implementation(libs.kotlinx.coroutines.swing)"], + "KMP example desktop coroutines must use version catalog", + ); + expectIncludes( + "libraries/kmp-iap/example/gradle/libs.versions.toml", + ["kotlinx-coroutines-swing"], + "KMP example coroutines aliases must match root catalog naming", + ); + expectNotIncludes( + "libraries/kmp-iap/example/gradle/libs.versions.toml", + ["kotlinx-coroutinesSwing"], + "KMP example coroutines aliases must not use root-incompatible camel suffixes", + ); + expectNotIncludes( + "libraries/kmp-iap/example/composeApp/build.gradle.kts", + ["org.jetbrains.kotlinx:kotlinx-coroutines-swing:"], + "KMP example desktop coroutines must not hardcode Maven versions", + ); + expectIncludes( + "libraries/kmp-iap/CONTRIBUTING.md", + [ + "JDK 17 or higher", + "https://github.com/hyodotdev/openiap/issues", + "https://github.com/hyodotdev/openiap/discussions/categories/kmp-iap", + ], + "KMP contributing JDK requirement", + ); + expectNotIncludes( + "libraries/kmp-iap/CONTRIBUTING.md", + ["github.com/hyochan/kmp-iap", "github.com/hyochan/openiap.dev"], + "KMP contributing links must point at the monorepo", + ); + expectIncludes( + "libraries/kmp-iap/CHANGELOG.md", + ["https://github.com/hyodotdev/openiap/releases?q=kmp-iap&expanded=true"], + "KMP changelog release link", + ); + expectNotIncludes( + "libraries/kmp-iap/CHANGELOG.md", + ["github.com/hyochan/kmp-iap"], + "KMP changelog must not point at the legacy standalone repository", + ); } if (!kmpCompileSdk || !kmpMinSdk || !kmpTargetSdk) { - fail('KMP example libs.versions.toml must declare Android SDK versions'); + fail("KMP example libs.versions.toml must declare Android SDK versions"); } else { - expectIncludes('packages/docs/src/lib/versioning.ts', [ - 'kmpCompileSdk', - 'kmpMinSdk', - 'kmpTargetSdk', - 'export const KMP_ANDROID_SDK', - ], 'docs KMP Android SDK metadata must derive from generated docs metadata'); - expectIncludes('packages/docs/src/pages/docs/setup/kmp.tsx', [ - 'compileSdk = ${KMP_ANDROID_SDK.compileSdk}', - 'minSdk = ${KMP_ANDROID_SDK.minSdk}', - 'targetSdk = ${KMP_ANDROID_SDK.targetSdk}', - ], 'KMP setup docs Android SDK versions'); - expectNotIncludes('packages/docs/src/pages/docs/setup/kmp.tsx', [ - `compileSdk = ${kmpCompileSdk}`, - `minSdk = ${kmpMinSdk}`, - `targetSdk = ${kmpTargetSdk}`, - ], 'KMP setup docs Android SDK versions must not be inline hardcoded'); - } - expectIncludes('libraries/kmp-iap/gradle/libs.versions.toml', [ - 'compose-material-icons = "1.7.3"', - 'compose-material-icons-extended', - 'version.ref = "compose-material-icons"', - ], 'KMP root Compose icon dependency'); - expectIncludes('libraries/kmp-iap/example/gradle/libs.versions.toml', [ - 'compose-material-icons = "1.7.3"', - 'compose-material-icons-extended', - 'version.ref = "compose-material-icons"', - ], 'KMP standalone example Compose icon dependency'); - expectIncludes('libraries/kmp-iap/example/composeApp/build.gradle.kts', [ - 'libs.compose.material.icons.extended', - '-Xexpect-actual-classes', - ], 'KMP example Compose icon dependency'); - expectIncludes('libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt', [ - 'List', - 'FetchProductsResultSubscriptions', - ], 'KMP example subscription product handling'); - expectNotIncludes('libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt', [ - 'is ProductAndroid', - 'is ProductIOS', - ], 'KMP example subscription product handling must not type-check impossible product variants'); + expectIncludes( + "packages/docs/src/lib/versioning.ts", + [ + "kmpCompileSdk", + "kmpMinSdk", + "kmpTargetSdk", + "export const KMP_ANDROID_SDK", + ], + "docs KMP Android SDK metadata must derive from generated docs metadata", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/kmp.tsx", + [ + "compileSdk = ${KMP_ANDROID_SDK.compileSdk}", + "minSdk = ${KMP_ANDROID_SDK.minSdk}", + "targetSdk = ${KMP_ANDROID_SDK.targetSdk}", + ], + "KMP setup docs Android SDK versions", + ); + expectNotIncludes( + "packages/docs/src/pages/docs/setup/kmp.tsx", + [ + `compileSdk = ${kmpCompileSdk}`, + `minSdk = ${kmpMinSdk}`, + `targetSdk = ${kmpTargetSdk}`, + ], + "KMP setup docs Android SDK versions must not be inline hardcoded", + ); + } + expectIncludes( + "libraries/kmp-iap/gradle/libs.versions.toml", + [ + 'compose-material-icons = "1.7.3"', + "compose-material-icons-extended", + 'version.ref = "compose-material-icons"', + ], + "KMP root Compose icon dependency", + ); + expectIncludes( + "libraries/kmp-iap/example/gradle/libs.versions.toml", + [ + 'compose-material-icons = "1.7.3"', + "compose-material-icons-extended", + 'version.ref = "compose-material-icons"', + ], + "KMP standalone example Compose icon dependency", + ); + expectIncludes( + "libraries/kmp-iap/example/composeApp/build.gradle.kts", + ["libs.compose.material.icons.extended", "-Xexpect-actual-classes"], + "KMP example Compose icon dependency", + ); + expectIncludes( + "libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt", + ["List", "FetchProductsResultSubscriptions"], + "KMP example subscription product handling", + ); + expectNotIncludes( + "libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt", + ["is ProductAndroid", "is ProductIOS"], + "KMP example subscription product handling must not type-check impossible product variants", + ); if (mauiVersion) { - expectIncludes('packages/docs/src/lib/versioning.ts', [ - 'mauiPackageId', - 'mauiPackageVersion', - 'MAUI_PACKAGE', - 'installCommand: `dotnet add package ${MAUI_PACKAGE_ID}`', - 'packageReference: ``', - ], 'docs MAUI package metadata must derive from generated docs metadata'); - expectIncludes('packages/docs/src/lib/images.ts', [ - 'MAUI_PACKAGE', - 'installCommand: MAUI_PACKAGE.installCommand', - ], 'docs MAUI library listing install command'); - expectIncludes('packages/docs/src/pages/docs/setup/maui.tsx', [ - "import { MAUI_PACKAGE } from '../../../lib/versioning'", - 'MAUI_PACKAGE.installCommand', - 'MAUI_PACKAGE.packageReference', - 'MAUI_PACKAGE.versionedNugetUrl', - ], 'MAUI setup docs package metadata'); - expectIncludes('libraries/maui-iap/README.md', [ - 'dotnet add package OpenIap.Maui', - 'https://www.nuget.org/packages/OpenIap.Maui', - ], 'MAUI README should reference the NuGet package without an inline version'); + expectIncludes( + "packages/docs/src/lib/versioning.ts", + [ + "mauiPackageId", + "mauiPackageVersion", + "MAUI_PACKAGE", + "installCommand: `dotnet add package ${MAUI_PACKAGE_ID}`", + 'packageReference: ``', + ], + "docs MAUI package metadata must derive from generated docs metadata", + ); + expectIncludes( + "packages/docs/src/lib/images.ts", + ["MAUI_PACKAGE", "installCommand: MAUI_PACKAGE.installCommand"], + "docs MAUI library listing install command", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/maui.tsx", + [ + "import { MAUI_PACKAGE } from '../../../lib/versioning'", + "MAUI_PACKAGE.installCommand", + "MAUI_PACKAGE.packageReference", + "MAUI_PACKAGE.versionedNugetUrl", + ], + "MAUI setup docs package metadata", + ); + expectIncludes( + "libraries/maui-iap/README.md", + [ + "dotnet add package OpenIap.Maui", + "https://www.nuget.org/packages/OpenIap.Maui", + ], + "MAUI README should reference the NuGet package without an inline version", + ); for (const mauiInstallDoc of [ - 'packages/docs/src/lib/images.ts', - 'packages/docs/src/pages/docs/setup/maui.tsx', - 'libraries/maui-iap/README.md', + "packages/docs/src/lib/images.ts", + "packages/docs/src/pages/docs/setup/maui.tsx", + "libraries/maui-iap/README.md", ]) { - expectNotIncludes(mauiInstallDoc, [ - `OpenIap.Maui --version ${mauiVersion}`, - ], 'MAUI install command version must not be inline hardcoded'); + expectNotIncludes( + mauiInstallDoc, + [`OpenIap.Maui --version ${mauiVersion}`], + "MAUI install command version must not be inline hardcoded", + ); } for (const mauiPackageReferenceDoc of [ - 'packages/docs/src/pages/docs/setup/maui.tsx', - 'libraries/maui-iap/README.md', + "packages/docs/src/pages/docs/setup/maui.tsx", + "libraries/maui-iap/README.md", ]) { - expectNotIncludes(mauiPackageReferenceDoc, [ - ``, - ], 'MAUI PackageReference version must not be inline hardcoded'); + expectNotIncludes( + mauiPackageReferenceDoc, + [ + ``, + ], + "MAUI PackageReference version must not be inline hardcoded", + ); } } - expectIncludes('scripts/sync-versions.mjs', [ - 'openiap-versions.json', - './scripts/sync-versions.sh', - ], 'root version sync script'); - expectNotIncludes('scripts/sync-versions.mjs', [ - 'writeFileSync', - 'pkgJson.version', - 'const packages = [', - ], 'root version sync wrapper must delegate writes to scripts/sync-versions.sh'); - expectIncludes('scripts/sync-versions.sh', [ - 'set -euo pipefail', - 'sync_package_json_version "packages/gql/package.json" "spec"', - 'sync_package_json_version "packages/docs/package.json" "spec"', - 'sync_package_json_version "packages/google/package.json" "google"', - 'sync_package_json_version "packages/apple/package.json" "apple"', - ], 'shell version sync must update package metadata for release workflows'); - if (exists('packages/gql/package-lock.json')) { - fail('packages/gql must not keep a stale npm package-lock.json; Bun lockfiles are the package-manager SSOT'); - } - expectIncludes('packages/gql/README.md', [ - 'bun install --frozen-lockfile', - ], 'GQL README must document Bun installs'); - expectNotIncludes('packages/gql/README.md', [ - 'npm install', - ], 'GQL README must not document npm installs'); - expectIncludes('.gitignore', [ - 'package-lock.json', - ], 'root gitignore must prevent npm lockfile drift in Bun-managed packages'); + expectIncludes( + "scripts/sync-versions.mjs", + ["openiap-versions.json", "./scripts/sync-versions.sh"], + "root version sync script", + ); + expectNotIncludes( + "scripts/sync-versions.mjs", + ["writeFileSync", "pkgJson.version", "const packages = ["], + "root version sync wrapper must delegate writes to scripts/sync-versions.sh", + ); + expectIncludes( + "scripts/sync-versions.sh", + [ + "set -euo pipefail", + 'sync_package_json_version "packages/gql/package.json" "spec"', + 'sync_package_json_version "packages/docs/package.json" "spec"', + 'sync_package_json_version "packages/google/package.json" "google"', + 'sync_package_json_version "packages/apple/package.json" "apple"', + ], + "shell version sync must update package metadata for release workflows", + ); + if (exists("packages/gql/package-lock.json")) { + fail( + "packages/gql must not keep a stale npm package-lock.json; Bun lockfiles are the package-manager SSOT", + ); + } + expectIncludes( + "packages/gql/README.md", + ["bun install --frozen-lockfile"], + "GQL README must document Bun installs", + ); + expectNotIncludes( + "packages/gql/README.md", + ["npm install"], + "GQL README must not document npm installs", + ); + expectIncludes( + ".gitignore", + ["package-lock.json"], + "root gitignore must prevent npm lockfile drift in Bun-managed packages", + ); for (const bunPackageJson of [ - 'packages/gql/package.json', - 'packages/docs/package.json', - 'packages/google/package.json', - 'packages/apple/package.json', + "packages/gql/package.json", + "packages/docs/package.json", + "packages/google/package.json", + "packages/apple/package.json", ]) { - expectIncludes(bunPackageJson, [ - '"packageManager": "bun@1.3.13"', - ], `${bunPackageJson} package manager must match the monorepo Bun version`); + expectIncludes( + bunPackageJson, + ['"packageManager": "bun@1.3.13"'], + `${bunPackageJson} package manager must match the monorepo Bun version`, + ); } for (const bunWorkflow of [ - '.github/workflows/ci.yml', - '.github/workflows/ci-expo-iap.yml', - '.github/workflows/release-expo.yml', - '.github/workflows/release.yml', + ".github/workflows/ci.yml", + ".github/workflows/ci-expo-iap.yml", + ".github/workflows/release-expo.yml", + ".github/workflows/release.yml", ]) { - expectIncludes(bunWorkflow, [ - 'bun-version: 1.3.13', - ], `${bunWorkflow} must pin Bun to the monorepo packageManager version`); - expectNotIncludes(bunWorkflow, [ - 'bun-version: "1.1.38"', - 'bun-version: latest', - ], `${bunWorkflow} must not drift from the monorepo Bun version`); + expectIncludes( + bunWorkflow, + ["bun-version: 1.3.13"], + `${bunWorkflow} must pin Bun to the monorepo packageManager version`, + ); + expectNotIncludes( + bunWorkflow, + ['bun-version: "1.1.38"', "bun-version: latest"], + `${bunWorkflow} must not drift from the monorepo Bun version`, + ); } for (const bunInstallWorkflow of [ - '.github/workflows/ci.yml', - '.github/workflows/ci-expo-iap.yml', - '.github/workflows/release-expo.yml', - '.github/workflows/release.yml', + ".github/workflows/ci.yml", + ".github/workflows/ci-expo-iap.yml", + ".github/workflows/release-expo.yml", + ".github/workflows/release.yml", ]) { - expectIncludes(bunInstallWorkflow, [ - 'bun install --frozen-lockfile', - ], `${bunInstallWorkflow} must enforce lockfile installs`); - expectNotIncludes(bunInstallWorkflow, [ - 'run: bun install\n', - 'bun install && break', - ], `${bunInstallWorkflow} must not allow lockfile drift during installs`); - } - expectIncludes('packages/google/scripts/publish-local.sh', [ - '${openIapGroupId:-io.github.hyochan.openiap}:openiap-google', - ], 'Google publish-local coordinate hint'); - expectNotIncludes('packages/google/scripts/publish-local.sh', [ - '${openIapGroupId:-io.github.hyochan}:openiap-google', - ], 'Google publish-local coordinate hint must use published groupId'); - expectIncludes('packages/google/scripts/update-version.sh', [ - 'REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"', - "jq --arg version \"$VERSION\" '.google = $version'", - 'data["google"] = os.environ["VERSION"]', - '"$REPO_ROOT/scripts/sync-versions.sh"', - 'packages/*/openiap-versions.json', - 'packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json', - ], 'Google update-version must preserve openiap-versions.json fields'); - expectNotIncludes('packages/google/scripts/update-version.sh', [ - 'cat > "$VERSIONS_FILE"', - '"spec": "$SPEC_VERSION"', - '"google": "$VERSION"', - 'sed -i', - ], 'Google update-version must not rewrite openiap-versions.json or use platform sed'); - expectIncludes('packages/apple/scripts/bump-version.sh', [ - 'REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"', - "jq -er '.apple | select(type == \"string\" and length > 0)'", - 'python3 - "${VERSIONS_FILE}"', - 'raise SystemExit(f"missing apple in {path}")', - "jq --arg version \"$NEW_VERSION\" '.apple = $version'", - "data['apple'] = os.environ[\"VERSION\"]", - '"$REPO_ROOT/scripts/sync-versions.sh"', - 'packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json', - 'git commit -m "chore(apple): bump version to $NEW_VERSION"', - 'git pull --rebase origin main', - 'git push origin HEAD:main', - 'git ls-remote --exit-code --tags origin "refs/tags/$NEW_VERSION"', - ], 'Apple bump-version must preserve openiap-versions.json fields'); - expectNotIncludes('packages/apple/scripts/bump-version.sh', [ - 'openiap-apple.git', - 'sed -i', - 'chore: bump version', - 'git tag -d "$NEW_VERSION"', - ], 'Apple bump-version must use monorepo coordinates and conventional commits'); - expectIncludes('scripts/deploy.sh', [ - 'set -euo pipefail', - 'VERCEL_CLI_VERSION="54.0.0"', - 'npm install -g "vercel@$VERCEL_CLI_VERSION"', - 'if [ -z "${1:-}" ]; then', - 'if ! ./scripts/sync-versions.sh; then', - 'if ! bun run typecheck; then', - 'if ! bun run build; then', - 'if ! vercel --prod; then', - 'git commit -m "chore(spec): bump version to $VERSION"', - 'git pull --rebase origin main', - 'git push origin HEAD:main', - 'packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json', - ], 'deploy script commit message'); - expectNotIncludes('scripts/deploy.sh', [ - 'npm install -g vercel', - 'if [ $? -ne 0 ]; then', - ], 'deploy script must not install a floating Vercel CLI'); - expectIncludes('packages/docs/deploy.sh', [ - 'set -euo pipefail', - 'VERCEL_CLI_VERSION="54.0.0"', - 'npm install -g "vercel@$VERCEL_CLI_VERSION"', - 'if ! bun run typecheck; then', - 'if ! bun run build; then', - ], 'docs deploy script must be deterministic and preserve failure messages'); - expectNotIncludes('packages/docs/deploy.sh', [ - 'npm install -g vercel', - 'if [ $? -ne 0 ]; then', - ], 'docs deploy script must not install floating CLIs or bypass custom failure messages'); - expectIncludes('.github/workflows/release.yml', [ - 'git commit -m "chore(docs): bump version to $VERSION"', - "jq --arg version \"$VERSION\" '.spec = $version'", - 'git show HEAD:openiap-versions.json > /tmp/upstream-openiap-versions.json', - './scripts/sync-versions.sh', - 'packages/docs/src/generated/version-metadata.json', - 'if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then', - 'Tag $TAG_NAME already exists', - 'packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json', - ], 'docs release workflow commit message'); - expectNotIncludes('.github/workflows/release.yml', [ - 'git pull --rebase origin main || true', - ], 'docs release workflow must not hide version rebase conflicts'); + expectIncludes( + bunInstallWorkflow, + ["bun install --frozen-lockfile"], + `${bunInstallWorkflow} must enforce lockfile installs`, + ); + expectNotIncludes( + bunInstallWorkflow, + ["run: bun install\n", "bun install && break"], + `${bunInstallWorkflow} must not allow lockfile drift during installs`, + ); + } + expectIncludes( + "packages/google/scripts/publish-local.sh", + ["${openIapGroupId:-io.github.hyochan.openiap}:openiap-google"], + "Google publish-local coordinate hint", + ); + expectNotIncludes( + "packages/google/scripts/publish-local.sh", + ["${openIapGroupId:-io.github.hyochan}:openiap-google"], + "Google publish-local coordinate hint must use published groupId", + ); + expectIncludes( + "packages/google/scripts/update-version.sh", + [ + 'REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"', + "jq --arg version \"$VERSION\" '.google = $version'", + 'data["google"] = os.environ["VERSION"]', + '"$REPO_ROOT/scripts/sync-versions.sh"', + "packages/*/openiap-versions.json", + "packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json", + ], + "Google update-version must preserve openiap-versions.json fields", + ); + expectNotIncludes( + "packages/google/scripts/update-version.sh", + [ + 'cat > "$VERSIONS_FILE"', + '"spec": "$SPEC_VERSION"', + '"google": "$VERSION"', + "sed -i", + ], + "Google update-version must not rewrite openiap-versions.json or use platform sed", + ); + expectIncludes( + "packages/apple/scripts/bump-version.sh", + [ + 'REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"', + "jq -er '.apple | select(type == \"string\" and length > 0)'", + 'python3 - "${VERSIONS_FILE}"', + 'raise SystemExit(f"missing apple in {path}")', + "jq --arg version \"$NEW_VERSION\" '.apple = $version'", + "data['apple'] = os.environ[\"VERSION\"]", + '"$REPO_ROOT/scripts/sync-versions.sh"', + "packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json", + 'git commit -m "chore(apple): bump version to $NEW_VERSION"', + "git pull --rebase origin main", + "git push origin HEAD:main", + 'git ls-remote --exit-code --tags origin "refs/tags/$NEW_VERSION"', + ], + "Apple bump-version must preserve openiap-versions.json fields", + ); + expectNotIncludes( + "packages/apple/scripts/bump-version.sh", + [ + "openiap-apple.git", + "sed -i", + "chore: bump version", + 'git tag -d "$NEW_VERSION"', + ], + "Apple bump-version must use monorepo coordinates and conventional commits", + ); + expectIncludes( + "scripts/deploy.sh", + [ + "set -euo pipefail", + 'VERCEL_CLI_VERSION="54.0.0"', + 'npm install -g "vercel@$VERCEL_CLI_VERSION"', + 'if [ -z "${1:-}" ]; then', + "if ! ./scripts/sync-versions.sh; then", + "if ! bun run typecheck; then", + "if ! bun run build; then", + "if ! vercel --prod; then", + 'git commit -m "chore(spec): bump version to $VERSION"', + "git pull --rebase origin main", + "git push origin HEAD:main", + "packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json", + ], + "deploy script commit message", + ); + expectNotIncludes( + "scripts/deploy.sh", + ["npm install -g vercel", "if [ $? -ne 0 ]; then"], + "deploy script must not install a floating Vercel CLI", + ); + expectIncludes( + "packages/docs/deploy.sh", + [ + "set -euo pipefail", + 'VERCEL_CLI_VERSION="54.0.0"', + 'npm install -g "vercel@$VERCEL_CLI_VERSION"', + "if ! bun run typecheck; then", + "if ! bun run build; then", + ], + "docs deploy script must be deterministic and preserve failure messages", + ); + expectNotIncludes( + "packages/docs/deploy.sh", + ["npm install -g vercel", "if [ $? -ne 0 ]; then"], + "docs deploy script must not install floating CLIs or bypass custom failure messages", + ); + expectIncludes( + ".github/workflows/release.yml", + [ + 'git commit -m "chore(docs): bump version to $VERSION"', + "jq --arg version \"$VERSION\" '.spec = $version'", + "git show HEAD:openiap-versions.json > /tmp/upstream-openiap-versions.json", + "./scripts/sync-versions.sh", + "packages/docs/src/generated/version-metadata.json", + 'if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then', + "Tag $TAG_NAME already exists", + "packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json", + ], + "docs release workflow commit message", + ); + expectNotIncludes( + ".github/workflows/release.yml", + ["git pull --rebase origin main || true"], + "docs release workflow must not hide version rebase conflicts", + ); for (const releaseWorkflow of [ - '.github/workflows/release-apple.yml', - '.github/workflows/release-google.yml', + ".github/workflows/release-apple.yml", + ".github/workflows/release-google.yml", ]) { - expectIncludes(releaseWorkflow, [ - 'openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json', - 'git show HEAD:openiap-versions.json > /tmp/upstream-openiap-versions.json', - 'Re-sync package metadata and docs copy after merge', - './scripts/sync-versions.sh', - 'packages/docs/src/generated/version-metadata.json', - 'packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json', - ], `${releaseWorkflow} must commit package metadata synced from openiap-versions.json`); - expectNotIncludes(releaseWorkflow, [ - 'cp openiap-versions.json packages/docs/openiap-versions.json', - 'git show HEAD:"$conflict_file"', - '/tmp/theirs.json', - ], `${releaseWorkflow} must not only sync the docs copy after conflict resolution`); - } - expectIncludes('.github/workflows/release-apple.yml', [ - 'COCOAPODS_VERSION: 1.15.2', - 'gem install cocoapods -v "$COCOAPODS_VERSION"', - 'bare_exists=true', - 'bare_exists=false', - 'Checkout release tag (current version)', - 'LEGACY_TAG="apple-v$VERSION"', - "steps.check_tag.outputs.bare_exists != 'true'", - 'Cleaning up tag created by this run', - ], 'Apple release workflow must not delete pre-existing tags on validation failure'); - expectNotIncludes('.github/workflows/release-apple.yml', [ - 'gem install cocoapods\n', - ], 'Apple release workflow must pin CocoaPods'); - expectIncludes('.github/workflows/release-google.yml', [ - 'release:', - 'runs-on: ubuntu-latest', - './gradlew :openiap:assembleRelease --no-daemon --stacktrace', - 'artifacts=(openiap/build/outputs/aar/*.aar openiap/build/libs/*.jar)', - 'No Google release artifacts found', - 'cp "${artifacts[@]}" release-artifacts/', - 'Checkout release tag (current version)', - 'LEGACY_TAG="google-v$VERSION"', - 'HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://repo1.maven.org/maven2/io/github/hyochan/openiap/openiap-google/$VERSION/" || true)', - 'HTTP_STATUS="${HTTP_STATUS:-000}"', - 'Unable to verify openiap-google $VERSION on Maven Central', - 'if gh release view "google-$VERSION" >/dev/null 2>&1; then', - 'gh release edit "google-$VERSION"', - 'gh release upload "google-$VERSION" $ARTIFACTS --clobber', - ], 'Google release workflow must not require a macOS runner'); + expectIncludes( + releaseWorkflow, + [ + "openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json", + "git show HEAD:openiap-versions.json > /tmp/upstream-openiap-versions.json", + "Re-sync package metadata and docs copy after merge", + "./scripts/sync-versions.sh", + "packages/docs/src/generated/version-metadata.json", + "packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json", + ], + `${releaseWorkflow} must commit package metadata synced from openiap-versions.json`, + ); + expectNotIncludes( + releaseWorkflow, + [ + "cp openiap-versions.json packages/docs/openiap-versions.json", + 'git show HEAD:"$conflict_file"', + "/tmp/theirs.json", + ], + `${releaseWorkflow} must not only sync the docs copy after conflict resolution`, + ); + } + expectIncludes( + ".github/workflows/release-apple.yml", + [ + "COCOAPODS_VERSION: 1.15.2", + 'gem install cocoapods -v "$COCOAPODS_VERSION"', + "bare_exists=true", + "bare_exists=false", + "Checkout release tag (current version)", + 'LEGACY_TAG="apple-v$VERSION"', + "steps.check_tag.outputs.bare_exists != 'true'", + "Cleaning up tag created by this run", + ], + "Apple release workflow must not delete pre-existing tags on validation failure", + ); + expectNotIncludes( + ".github/workflows/release-apple.yml", + ["gem install cocoapods\n"], + "Apple release workflow must pin CocoaPods", + ); + expectIncludes( + ".github/workflows/release-google.yml", + [ + "release:", + "runs-on: ubuntu-latest", + "./gradlew :openiap:assembleRelease --no-daemon --stacktrace", + "artifacts=(openiap/build/outputs/aar/*.aar openiap/build/libs/*.jar)", + "No Google release artifacts found", + 'cp "${artifacts[@]}" release-artifacts/', + "Checkout release tag (current version)", + 'LEGACY_TAG="google-v$VERSION"', + 'HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://repo1.maven.org/maven2/io/github/hyochan/openiap/openiap-google/$VERSION/" || true)', + 'HTTP_STATUS="${HTTP_STATUS:-000}"', + "Unable to verify openiap-google $VERSION on Maven Central", + 'if gh release view "google-$VERSION" >/dev/null 2>&1; then', + 'gh release edit "google-$VERSION"', + 'gh release upload "google-$VERSION" $ARTIFACTS --clobber', + ], + "Google release workflow must not require a macOS runner", + ); for (const [frameworkReleaseWorkflow, tagCommand] of [ - ['.github/workflows/release-expo.yml', 'git tag -a "expo-iap-${NEW_VERSION}"'], - ['.github/workflows/release-react-native.yml', 'git tag -a "react-native-iap-${NEW_VERSION}"'], - ['.github/workflows/release-flutter.yml', 'git tag -a "flutter-iap-${NEW_VERSION}"'], - ['.github/workflows/release-godot.yml', 'git tag -a godot-iap-${{ steps.version.outputs.VERSION }}'], + [ + ".github/workflows/release-expo.yml", + 'git tag -a "expo-iap-${NEW_VERSION}"', + ], + [ + ".github/workflows/release-react-native.yml", + 'git tag -a "react-native-iap-${NEW_VERSION}"', + ], + [ + ".github/workflows/release-flutter.yml", + 'git tag -a "flutter-iap-${NEW_VERSION}"', + ], + [ + ".github/workflows/release-godot.yml", + "git tag -a godot-iap-${{ steps.version.outputs.VERSION }}", + ], ]) { - expectIncludes(frameworkReleaseWorkflow, [ - 'Check if release tag already exists', - 'Checkout release tag (current version)', - 'git checkout "$RELEASE_TAG"', - 'Use \'current\' to retry this version.', - 'Commit version update', - 'STASHED=false', - 'git stash push --include-untracked -m "release artifacts"', - 'git pull --rebase origin main', - 'if [ "$STASHED" = "true" ]; then', - 'git stash pop', - tagCommand, - 'git push origin HEAD:main --follow-tags', - ], `${frameworkReleaseWorkflow} must tag after rebasing the release commit`); - expectNotIncludes(frameworkReleaseWorkflow, [ - 'git stash --include-untracked', - 'git stash pop || true', - 'git push --follow-tags', - 'git tag -af', - 'git push origin "flutter-iap-${NEW_VERSION}" --force', - 'create_release:', - 'inputs.create_release', - ], `${frameworkReleaseWorkflow} must push tags from the rebased HEAD explicitly`); + expectIncludes( + frameworkReleaseWorkflow, + [ + "Check if release tag already exists", + "Checkout release tag (current version)", + 'git checkout "$RELEASE_TAG"', + "Use 'current' to retry this version.", + "Commit version update", + "STASHED=false", + 'git stash push --include-untracked -m "release artifacts"', + "git pull --rebase origin main", + 'if [ "$STASHED" = "true" ]; then', + "git stash pop", + tagCommand, + "git push origin HEAD:main --follow-tags", + ], + `${frameworkReleaseWorkflow} must tag after rebasing the release commit`, + ); + expectNotIncludes( + frameworkReleaseWorkflow, + [ + "git stash --include-untracked", + "git stash pop || true", + "git push --follow-tags", + "git tag -af", + 'git push origin "flutter-iap-${NEW_VERSION}" --force', + "create_release:", + "inputs.create_release", + ], + `${frameworkReleaseWorkflow} must push tags from the rebased HEAD explicitly`, + ); } for (const [npmReleaseWorkflow, npmPackage] of [ - ['.github/workflows/release-expo.yml', 'expo-iap'], - ['.github/workflows/release-react-native.yml', 'react-native-iap'], + [".github/workflows/release-expo.yml", "expo-iap"], + [".github/workflows/release-react-native.yml", "react-native-iap"], ]) { - expectIncludes(npmReleaseWorkflow, [ - 'Check if npm package already published', - 'npm install -g npm@11.5.1', - `npm view "${npmPackage}@$VERSION" version`, - `if NPM_OUTPUT=$(npm view "${npmPackage}@$VERSION" version 2>&1); then`, - "grep -qiE 'E404|404 Not Found'", - `npm view "${npmPackage}@$NEW_VERSION" gitHead`, - 'git cat-file -e "$PUBLISHED_GIT_HEAD^{commit}"', - "if: steps.check_npm.outputs.exists == 'false'", - ], `${npmReleaseWorkflow} must support npm release reruns`); - expectNotIncludes(npmReleaseWorkflow, [ - 'npm install -g npm@latest', - 'node-version: 20.x', - 'set +e', - 'NPM_STATUS=$?', - ], `${npmReleaseWorkflow} must not drift npm trusted-publishing CLI version`); - } - expectIncludes('scripts/verify-npm-consumer-install.mjs', [ - 'npm', - 'pack', - '--pack-destination', - '--ignore-scripts', - 'openiap-versions.json must be packed as a real file', - 'consumer install smoke test passed', - ], 'npm consumer install smoke helper'); + expectIncludes( + npmReleaseWorkflow, + [ + "Check if npm package already published", + "npm install -g npm@11.5.1", + `npm view "${npmPackage}@$VERSION" version`, + `if NPM_OUTPUT=$(npm view "${npmPackage}@$VERSION" version 2>&1); then`, + "grep -qiE 'E404|404 Not Found'", + `npm view "${npmPackage}@$NEW_VERSION" gitHead`, + 'git cat-file -e "$PUBLISHED_GIT_HEAD^{commit}"', + "if: steps.check_npm.outputs.exists == 'false'", + ], + `${npmReleaseWorkflow} must support npm release reruns`, + ); + expectNotIncludes( + npmReleaseWorkflow, + [ + "npm install -g npm@latest", + "node-version: 20.x", + "set +e", + "NPM_STATUS=$?", + ], + `${npmReleaseWorkflow} must not drift npm trusted-publishing CLI version`, + ); + } + expectIncludes( + "scripts/verify-npm-consumer-install.mjs", + [ + "npm", + "pack", + "--pack-destination", + "--ignore-scripts", + "openiap-versions.json must be packed as a real file", + "consumer install smoke test passed", + ], + "npm consumer install smoke helper", + ); for (const [packageJsonPath, packageName] of [ - ['libraries/expo-iap/package.json', 'expo-iap'], - ['libraries/react-native-iap/package.json', 'react-native-iap'], + ["libraries/expo-iap/package.json", "expo-iap"], + ["libraries/react-native-iap/package.json", "react-native-iap"], ]) { - expectIncludes(packageJsonPath, [ - '"verify:consumer-install"', - 'verify-npm-consumer-install.mjs', - `--package-name ${packageName}`, - '--required openiap-versions.json', - ], `${packageJsonPath} must expose npm consumer smoke test`); + expectIncludes( + packageJsonPath, + [ + '"verify:consumer-install"', + "verify-npm-consumer-install.mjs", + `--package-name ${packageName}`, + "--required openiap-versions.json", + ], + `${packageJsonPath} must expose npm consumer smoke test`, + ); } for (const [workflowPath, command] of [ - ['.github/workflows/ci-expo-iap.yml', 'bun run verify:consumer-install'], - ['.github/workflows/ci-react-native-iap.yml', 'yarn verify:consumer-install'], - ['.github/workflows/release-expo.yml', 'bun run verify:consumer-install --pack-ignore-scripts'], - ['.github/workflows/release-react-native.yml', 'verify:consumer-install --pack-ignore-scripts'], + [".github/workflows/ci-expo-iap.yml", "bun run verify:consumer-install"], + [ + ".github/workflows/ci-react-native-iap.yml", + "yarn verify:consumer-install", + ], + [ + ".github/workflows/release-expo.yml", + "bun run verify:consumer-install --pack-ignore-scripts", + ], + [ + ".github/workflows/release-react-native.yml", + "verify:consumer-install --pack-ignore-scripts", + ], ]) { - expectIncludes(workflowPath, [ - 'Consumer install smoke test', - command, - ], `${workflowPath} must run npm consumer smoke test`); - } - expectIncludes('.github/workflows/publish-flutter.yml', [ - 'Check if pub.dev package already published', - 'flutter-version: "3.41.9"', - 'HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}"', - 'HTTP_STATUS="${HTTP_STATUS:-000}"', - 'https://pub.dev/api/packages/flutter_inapp_purchase/versions/$VERSION', - "if: steps.check_pub.outputs.exists == 'false'", - ], 'Flutter publish workflow must support pub.dev release reruns'); + expectIncludes( + workflowPath, + ["Consumer install smoke test", command], + `${workflowPath} must run npm consumer smoke test`, + ); + } + expectIncludes( + ".github/workflows/publish-flutter.yml", + [ + "Check if pub.dev package already published", + 'flutter-version: "3.41.9"', + 'HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}"', + 'HTTP_STATUS="${HTTP_STATUS:-000}"', + "https://pub.dev/api/packages/flutter_inapp_purchase/versions/$VERSION", + "if: steps.check_pub.outputs.exists == 'false'", + ], + "Flutter publish workflow must support pub.dev release reruns", + ); for (const flutterWorkflow of [ - '.github/workflows/ci-flutter-inapp-purchase.yml', - '.github/workflows/release-flutter.yml', - '.github/workflows/publish-flutter.yml', + ".github/workflows/ci-flutter-inapp-purchase.yml", + ".github/workflows/release-flutter.yml", + ".github/workflows/publish-flutter.yml", ]) { - expectIncludes(flutterWorkflow, [ - 'flutter-version: "3.41.9"', - ], `${flutterWorkflow} must pin Flutter SDK`); - expectNotIncludes(flutterWorkflow, [ - 'flutter-version: "3.x"', - ], `${flutterWorkflow} must not float Flutter SDK`); + expectIncludes( + flutterWorkflow, + ['flutter-version: "3.41.9"'], + `${flutterWorkflow} must pin Flutter SDK`, + ); + expectNotIncludes( + flutterWorkflow, + ['flutter-version: "3.x"'], + `${flutterWorkflow} must not float Flutter SDK`, + ); } for (const releaseNotesWorkflow of [ - '.github/workflows/release-apple.yml', - '.github/workflows/release-google.yml', - '.github/workflows/release-kmp.yml', - '.github/workflows/release-maui.yml', - '.github/workflows/release-expo.yml', - '.github/workflows/release-react-native.yml', - '.github/workflows/release-flutter.yml', - '.github/workflows/release-godot.yml', + ".github/workflows/release-apple.yml", + ".github/workflows/release-google.yml", + ".github/workflows/release-kmp.yml", + ".github/workflows/release-maui.yml", + ".github/workflows/release-expo.yml", + ".github/workflows/release-react-native.yml", + ".github/workflows/release-flutter.yml", + ".github/workflows/release-godot.yml", ]) { - expectIncludes(releaseNotesWorkflow, [ - 'RELEASE_REF="HEAD"', - '..$RELEASE_REF', - ], `${releaseNotesWorkflow} release notes must use the release tag when it already exists`); + expectIncludes( + releaseNotesWorkflow, + ['RELEASE_REF="HEAD"', "..$RELEASE_REF"], + `${releaseNotesWorkflow} release notes must use the release tag when it already exists`, + ); } for (const xcodeReleaseWorkflow of [ - '.github/workflows/ci.yml', - '.github/workflows/ci-maui-iap.yml', - '.github/workflows/release-apple.yml', - '.github/workflows/release-kmp.yml', - '.github/workflows/release-maui.yml', + ".github/workflows/ci.yml", + ".github/workflows/ci-maui-iap.yml", + ".github/workflows/release-apple.yml", + ".github/workflows/release-kmp.yml", + ".github/workflows/release-maui.yml", ]) { - expectIncludes(xcodeReleaseWorkflow, [ - 'runs-on: macos-15', - 'XCODE_VERSION: 16.4', - 'maxim-lobanov/setup-xcode@v1', - 'xcode-version: ${{ env.XCODE_VERSION }}', - ], `${xcodeReleaseWorkflow} must pin the macOS/Xcode release image`); - expectNotIncludes(xcodeReleaseWorkflow, [ - 'runs-on: macos-latest', - 'sudo xcode-select -s /Applications/Xcode.app', - ], `${xcodeReleaseWorkflow} must not drift with macos-latest Xcode`); - } - expectIncludes('scripts/install-xcodegen.sh', [ - 'XCODEGEN_VERSION', - 'XCODEGEN_SHA256', - 'https://github.com/yonaskolb/XcodeGen/releases/download/${VERSION}/xcodegen.zip', - 'shasum -a 256 -c -', - 'install -m 0755', - 'GITHUB_PATH', - ], 'XcodeGen installer must pin release artifacts'); + expectIncludes( + xcodeReleaseWorkflow, + [ + "runs-on: macos-15", + "XCODE_VERSION: 16.4", + "maxim-lobanov/setup-xcode@v1", + "xcode-version: ${{ env.XCODE_VERSION }}", + ], + `${xcodeReleaseWorkflow} must pin the macOS/Xcode release image`, + ); + expectNotIncludes( + xcodeReleaseWorkflow, + ["runs-on: macos-latest", "sudo xcode-select -s /Applications/Xcode.app"], + `${xcodeReleaseWorkflow} must not drift with macos-latest Xcode`, + ); + } + expectIncludes( + "scripts/install-xcodegen.sh", + [ + "XCODEGEN_VERSION", + "XCODEGEN_SHA256", + "https://github.com/yonaskolb/XcodeGen/releases/download/${VERSION}/xcodegen.zip", + "shasum -a 256 -c -", + "install -m 0755", + "GITHUB_PATH", + ], + "XcodeGen installer must pin release artifacts", + ); for (const xcodegenWorkflow of [ - '.github/workflows/ci-maui-iap.yml', - '.github/workflows/release-maui.yml', + ".github/workflows/ci-maui-iap.yml", + ".github/workflows/release-maui.yml", ]) { - expectIncludes(xcodegenWorkflow, [ - 'XCODEGEN_VERSION: 2.45.4', - 'XCODEGEN_SHA256: 090ec29491aad50aec10631bf6e62253fed733c50f3aab0f5ffc86bc170bdbef', - 'bash scripts/install-xcodegen.sh "$XCODEGEN_VERSION"', - ], `${xcodegenWorkflow} must pin XcodeGen`); - expectNotIncludes(xcodegenWorkflow, [ - 'brew install xcodegen', - ], `${xcodegenWorkflow} must not float XcodeGen via Homebrew`); - } - expectIncludes('packages/apple/scripts/build-xcframework.sh', [ - 'scripts/install-xcodegen.sh ', - ], 'Apple xcframework script must point to pinned XcodeGen installer'); - expectIncludes('scripts/bump-version.mjs', [ - 'const currentVersion = versions[t];', - '${currentVersion} → ${newVersion}', - 'chore(version): bump to X.X.X', - "target === 'apple'", - "`google-${bumpedVersions.google}`", - "`docs-${bumpedVersions.spec}`", - ], 'root bump-version output'); - expectIncludes('knowledge/internal/06-git-deployment.md', [ - 'Creates Git tag `` (bare semver)', - 'Creates Git tag `google-`', - 'Create Git tag `docs-`', - ], 'release deployment docs tag conventions'); - expectNotIncludes('scripts/deploy.sh', [ - 'git commit -m "chore: bump spec', - ], 'deploy script commit message must be conventional'); - expectNotIncludes('.github/workflows/release.yml', [ - 'git commit -m "chore: bump docs', - ], 'docs release workflow commit message must be conventional'); - expectNotIncludes('scripts/bump-version.mjs', [ - 'chore: bump version', - '${versions[t]} → ${newVersion}', - 'git tag vX.X.X', - ], 'root bump-version output must be accurate and conventional'); - expectNotIncludes('knowledge/internal/06-git-deployment.md', [ - 'Creates Git tag `apple-v`', - 'Creates Git tag `google-v`', - 'Create Git tag `v`', - ], 'release deployment docs must not mention legacy tag creation'); + expectIncludes( + xcodegenWorkflow, + [ + "XCODEGEN_VERSION: 2.45.4", + "XCODEGEN_SHA256: 090ec29491aad50aec10631bf6e62253fed733c50f3aab0f5ffc86bc170bdbef", + 'bash scripts/install-xcodegen.sh "$XCODEGEN_VERSION"', + ], + `${xcodegenWorkflow} must pin XcodeGen`, + ); + expectNotIncludes( + xcodegenWorkflow, + ["brew install xcodegen"], + `${xcodegenWorkflow} must not float XcodeGen via Homebrew`, + ); + } + expectIncludes( + "packages/apple/scripts/build-xcframework.sh", + ["scripts/install-xcodegen.sh "], + "Apple xcframework script must point to pinned XcodeGen installer", + ); + expectIncludes( + "scripts/bump-version.mjs", + [ + "const currentVersion = versions[t];", + "${currentVersion} → ${newVersion}", + "chore(version): bump to X.X.X", + "target === 'apple'", + "`google-${bumpedVersions.google}`", + "`docs-${bumpedVersions.spec}`", + ], + "root bump-version output", + ); + expectIncludes( + "knowledge/internal/06-git-deployment.md", + [ + "Creates Git tag `` (bare semver)", + "Creates Git tag `google-`", + "Create Git tag `docs-`", + ], + "release deployment docs tag conventions", + ); + expectNotIncludes( + "scripts/deploy.sh", + ['git commit -m "chore: bump spec'], + "deploy script commit message must be conventional", + ); + expectNotIncludes( + ".github/workflows/release.yml", + ['git commit -m "chore: bump docs'], + "docs release workflow commit message must be conventional", + ); + expectNotIncludes( + "scripts/bump-version.mjs", + ["chore: bump version", "${versions[t]} → ${newVersion}", "git tag vX.X.X"], + "root bump-version output must be accurate and conventional", + ); + expectNotIncludes( + "knowledge/internal/06-git-deployment.md", + [ + "Creates Git tag `apple-v`", + "Creates Git tag `google-v`", + "Create Git tag `v`", + ], + "release deployment docs must not mention legacy tag creation", + ); for (const monorepoContribDoc of [ - 'packages/apple/CONTRIBUTING.md', - 'packages/google/CONTRIBUTING.md', - 'libraries/kmp-iap/CONTRIBUTING.md', + "packages/apple/CONTRIBUTING.md", + "packages/google/CONTRIBUTING.md", + "libraries/kmp-iap/CONTRIBUTING.md", ]) { - expectNotIncludes(monorepoContribDoc, [ - 'openiap-apple.git', - 'openiap-google.git', - 'github.com/hyodotdev/openiap-apple', - 'github.com/hyodotdev/openiap-google', - "git commit -m 'Add amazing feature'", - ], 'contributing docs must describe the monorepo workflow'); - } - expectNotIncludes('scripts/sync-versions.mjs', [ - "resolve(rootDir, 'versions.json')", - 'Read versions.json', - ], 'root version sync script must use openiap-versions.json'); - expectIncludes('scripts/bump-version.mjs', [ - 'openiap-versions.json', - ], 'root version bump script'); - expectNotIncludes('scripts/bump-version.mjs', [ - "resolve(rootDir, 'versions.json')", - 'Updated versions.json', - 'packages/ios/Sources/OpenIapVersion.swift', - 'public static let current', - ], 'root version bump script must not use obsolete version paths'); - expectNotIncludes('knowledge/internal/04-platform-packages.md', [ - 'change `"gql"` version', - 'update the `gql` field', - ], 'platform package docs must use openiap-versions.json spec key'); + expectNotIncludes( + monorepoContribDoc, + [ + "openiap-apple.git", + "openiap-google.git", + "github.com/hyodotdev/openiap-apple", + "github.com/hyodotdev/openiap-google", + "git commit -m 'Add amazing feature'", + ], + "contributing docs must describe the monorepo workflow", + ); + } + expectNotIncludes( + "scripts/sync-versions.mjs", + ["resolve(rootDir, 'versions.json')", "Read versions.json"], + "root version sync script must use openiap-versions.json", + ); + expectIncludes( + "scripts/bump-version.mjs", + ["openiap-versions.json"], + "root version bump script", + ); + expectNotIncludes( + "scripts/bump-version.mjs", + [ + "resolve(rootDir, 'versions.json')", + "Updated versions.json", + "packages/ios/Sources/OpenIapVersion.swift", + "public static let current", + ], + "root version bump script must not use obsolete version paths", + ); + expectNotIncludes( + "knowledge/internal/04-platform-packages.md", + ['change `"gql"` version', "update the `gql` field"], + "platform package docs must use openiap-versions.json spec key", + ); for (const dependencyFile of [ - '.github/workflows/release-godot.yml', - 'libraries/flutter_inapp_purchase/android/build.gradle', - 'libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd', - 'libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap', - 'libraries/godot-iap/Makefile', - 'libraries/godot-iap/scripts/write-gdap.sh', + ".github/workflows/release-godot.yml", + "libraries/flutter_inapp_purchase/android/build.gradle", + "libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd", + "libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap", + "libraries/godot-iap/Makefile", + "libraries/godot-iap/scripts/write-gdap.sh", ]) { - expectNotIncludes(dependencyFile, [ - 'com.android.billingclient:billing', - ], 'Framework libraries must inherit Play Billing from openiap-google'); - } - - expectIncludes('libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd', [ - 'ANDROID_GDAP_PATH', - '_read_android_remote_dependencies', - 'FileAccess.get_file_as_string(ANDROID_GDAP_PATH)', - ], 'Godot export plugin dependency source'); - expectNotIncludes('libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd', [ - 'openiap-google:', - 'kotlinx-coroutines-android:', - ], 'Godot export plugin must read dependency versions from GDAP'); - expectIncludes('libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift', [ - 'ErrorCode.userCancelled.rawValue', - 'ErrorCode.developerError.rawValue', - 'ErrorCode.purchaseError.rawValue', - 'ErrorCode.syncError.rawValue', - '@available(*, deprecated, message: "Use promotedProductIOS signal with requestPurchase instead.")', - '@available(*, deprecated, message: "Use verifyPurchase instead.")', - ], 'Godot iOS purchase errors must emit OpenIAP error codes'); - expectNotIncludes('libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift', [ - '"USER_CANCELLED"', - '"MISSING_SKU"', - '"PURCHASE_FAILED"', - '"RESTORE_FAILED"', - ], 'Godot iOS purchase errors must not emit legacy custom codes'); + expectNotIncludes( + dependencyFile, + ["com.android.billingclient:billing"], + "Framework libraries must inherit Play Billing from openiap-google", + ); + } + + expectIncludes( + "libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd", + [ + "ANDROID_GDAP_PATH", + "_read_android_remote_dependencies", + "FileAccess.get_file_as_string(ANDROID_GDAP_PATH)", + ], + "Godot export plugin dependency source", + ); + expectNotIncludes( + "libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd", + ["openiap-google:", "kotlinx-coroutines-android:"], + "Godot export plugin must read dependency versions from GDAP", + ); + expectIncludes( + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift", + [ + "ErrorCode.userCancelled.rawValue", + "ErrorCode.developerError.rawValue", + "ErrorCode.purchaseError.rawValue", + "ErrorCode.syncError.rawValue", + '@available(*, deprecated, message: "Use promotedProductIOS signal with requestPurchase instead.")', + '@available(*, deprecated, message: "Use verifyPurchase instead.")', + ], + "Godot iOS purchase errors must emit OpenIAP error codes", + ); + expectNotIncludes( + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift", + [ + '"USER_CANCELLED"', + '"MISSING_SKU"', + '"PURCHASE_FAILED"', + '"RESTORE_FAILED"', + ], + "Godot iOS purchase errors must not emit legacy custom codes", + ); if (googleVersion) { - expectIncludes('libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap', [ - `io.github.hyochan.openiap:openiap-google:${googleVersion}`, - ], 'Godot Android GDAP OpenIAP dependency version'); + expectIncludes( + "libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap", + [`io.github.hyochan.openiap:openiap-google:${googleVersion}`], + "Godot Android GDAP OpenIAP dependency version", + ); } if (googleCoroutinesVersion) { - expectIncludes('libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap', [ - `org.jetbrains.kotlinx:kotlinx-coroutines-android:${googleCoroutinesVersion}`, - ], 'Godot Android GDAP coroutines dependency version'); - expectIncludes('libraries/godot-iap/scripts/write-gdap.sh', [ - 'set -euo pipefail', - 'GOOGLE_OPENIAP_BUILD=', - 'read_openiap_version()', - 'python3 - "$VERSIONS_FILE" "$1"', - 'OPENIAP_GOOGLE_VERSION="$(read_openiap_version google)"', - 'COROUTINES_VERSION=', - 'read_google_variable coroutinesVersion', - 'fallback_property kotlinxCoroutinesVersion', - 'remote=["io.github.hyochan.openiap:openiap-google:$OPENIAP_GOOGLE_VERSION", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$COROUTINES_VERSION"]', - ], 'Godot GDAP dependency writer'); - expectIncludes('libraries/godot-iap/scripts/sync-versions.sh', [ - 'set -euo pipefail', - 'GOOGLE_OPENIAP_BUILD=', - 'read_openiap_version()', - 'python3 - "$VERSIONS_FILE" "$1"', - 'read_google_variable coroutinesVersion', - 'fallback_property kotlinxCoroutinesVersion', - ], 'Godot version sync must read coroutines from packages/google when available'); - expectIncludes('libraries/godot-iap/scripts/build_android.sh', [ - 'set -euo pipefail', - ], 'Godot Android build script must fail on unset vars and pipeline failures'); - expectIncludes('libraries/godot-iap/Makefile', [ - 'scripts/write-gdap.sh', - ], 'Godot Makefile must use shared GDAP writer'); - expectIncludes('libraries/godot-iap/Makefile', [ - 'gh release list --repo hyodotdev/openiap', - 'startswith("godot-iap-")', - 'RELEASE_VERSION = $(patsubst godot-iap-%,%,$(RELEASE_TAG))', - 'https://github.com/hyodotdev/openiap/releases/download/$(RELEASE_TAG)/$(RELEASE_ZIP_NAME)', - ], 'Godot Makefile release test target must use monorepo release assets'); - expectNotIncludes('libraries/godot-iap/Makefile', [ - 'hyochan/godot-iap', - 'github.com/hyochan/godot-iap/releases', - 'RELEASE_ZIP_NAME = godot-iap-$(RELEASE_TAG).zip', - ], 'Godot Makefile release test target must not use standalone release assets'); - expectIncludes('.github/workflows/release-godot.yml', [ - './scripts/write-gdap.sh dist/addons/godot-iap/android/GodotIap.gdap', - 'https://openiap.dev/docs/setup/godot', - 'https://openiap.dev/docs/apis', - 'https://openiap.dev/docs/updates/releases', - ], 'Godot release workflow must use shared GDAP writer'); - expectNotIncludes('.github/workflows/release-godot.yml', [ - 'https://www.openiap.dev', - ], 'Godot release workflow docs links must use canonical OpenIAP URLs'); - } - expectIncludes('libraries/kmp-iap/native/InAppPurchaseBridge/Package.swift', [ - 'resolveOpenIapAppleVersion()', - 'resolveOpenIapApplePackageVersion', - 'openiap-versions.json', - 'openIapApplePackageVersion', - ], 'KMP native bridge OpenIAP Apple dependency version'); - expectNotIncludes('libraries/kmp-iap/native/InAppPurchaseBridge/Package.swift', [ - 'from: "1.2.5"', - 'return "2.1.9"', - 'Version(2, 1, 9)', - ], 'KMP native bridge OpenIAP Apple dependency version'); - expectIncludes( - 'libraries/kmp-iap/native/InAppPurchaseBridge/Sources/InAppPurchaseBridge/InAppPurchaseBridge.swift', - ['@_exported import OpenIAP'], - 'KMP native bridge SwiftPM target source', - ); - expectIncludes('libraries/kmp-iap/library/build.gradle.kts', [ - 'val kmpRootDir = projectDir.parentFile', - 'kmpRootDir.resolve("gradle.properties")', - 'kmpRootDir.resolve("openiap-versions.json")', - 'missing openiap-versions.json', - "'$key' version missing in openiap-versions.json", - 'openIapVersion("apple")', - 'openIapVersion("google")', - 'project.findProperty("libraryVersion")', - 'GenerateKmpIapVersionTask', - 'generateKmpIapVersion', - ], 'KMP Gradle native dependency versions'); - expectNotIncludes('libraries/kmp-iap/library/build.gradle.kts', [ - '?: "1.2.5"', - '?: "1.2.10"', - '1.0.0-alpha02', - '1.0.0-alpha04', - 'openiap-versions.json with kmp-iap version', - 'tasks.withType', - 'DEBUG:', - 'First 50 chars', - ], 'KMP Gradle native dependency versions must not silently fallback'); - expectIncludes('libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt', [ - 'override fun getVersion(): String = kmpIapVersionString("Android")', - ], 'KMP Android runtime version'); - expectIncludes('libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt', [ - 'override fun getVersion(): String = kmpIapVersionString("iOS")', - ], 'KMP iOS runtime version'); - expectNotIncludes('libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt', [ - 'ANDROID_VERSION', - '1.0.0-alpha02', - ], 'KMP Android runtime version must not be stale'); - expectNotIncludes('libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt', [ - '1.0.0-rc.2', - ], 'KMP iOS runtime version must not be stale'); - expectNotIncludes('libraries/kmp-iap/README.md', [ - '1.0.0-rc.2', - '1.0.0-alpha02', - ], 'KMP README version examples must not be stale'); - expectIncludes('.github/workflows/release-kmp.yml', [ - 'Update release metadata', - 'Check if release tag already exists', - 'Checkout release tag (current version)', - 'git checkout "$RELEASE_TAG"', - 'libraryVersion=$ENV{VERSION}', - './scripts/update-readme-version.sh "$VERSION"', - 'git pull --rebase origin main', - 'git push origin HEAD:main', - 'https://repo1.maven.org/maven2/io/github/hyochan/kmp-iap/$VERSION/', - 'Unable to verify kmp-iap $VERSION on Maven Central', - "if: steps.check_maven.outputs.exists == 'false'", - 'Create and push tag', - 'git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"', - 'git push origin "$RELEASE_TAG"', - './gradlew :library:assembleRelease --no-daemon --stacktrace', - 'files: libraries/kmp-iap/release-artifacts.zip', - 'implementation("io.github.hyochan:kmp-iap:$VERSION")', - 'implementation \'io.github.hyochan:kmp-iap:$VERSION\'', - 'central.sonatype.com/artifact/io.github.hyochan/kmp-iap/$VERSION', - ], 'KMP release workflow metadata sync'); - expectNotIncludes('.github/workflows/release-kmp.yml', [ - 'create_release:', - 'inputs.create_release', - ], 'KMP release workflow must always create GitHub releases'); - expectIncludes('packages/docs/src/pages/docs/setup/kmp.tsx', [ - 'https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap', - ], 'docs KMP Maven Central link'); - expectIncludes('packages/docs/src/lib/images.ts', [ - "documentationUrl: 'https://openiap.dev/docs/setup/kmp'", - ], 'KMP docs card should point at OpenIAP docs'); - expectNotIncludes('packages/docs/src/lib/images.ts', [ - "documentationUrl: 'https://hyochan.github.io/kmp-iap'", - ], 'KMP docs card must not point at the legacy standalone docs'); - expectIncludes('scripts/agent/compile-context.ts', [ - 'function readInstallationVersions()', - 'libraries/kmp-iap/gradle.properties', - 'libraries/godot-iap/addons/godot-iap/plugin.cfg', - 'libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj', - '([^<]+)<\\/PackageId>', - 'flutter pub add flutter_inapp_purchase', - 'io.github.hyochan:kmp-iap:${versions.kmp}', - 'godot-iap-${versions.godot}.zip', - 'dotnet add package ${versions.mauiPackageId}', - 'Current NuGet package version: ${versions.maui}', - 'https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap', - ], 'AI context compiler framework package versions'); - expectNotIncludes('scripts/agent/compile-context.ts', [ - 'flutter_inapp_purchase: ^${versions.flutter}', - 'io.github.hyochan:kmp-iap:', - 'OpenIap.Maui" Version="${versions.maui}"', - 'dotnet add package OpenIap.Maui', - ], 'AI context compiler install commands must not hardcode framework package versions'); - expectNotIncludes('.github/workflows/release-kmp.yml', [ - 'updatePodspecDependency', - 'updateReadmeVersion', - 'spec\\.version\\s+=', - 'library/library.podspec gradle.properties', - 'io.github.hyochan.openiap:kmp-iap', - 'central.sonatype.com/artifact/io.github.hyochan.openiap/kmp-iap', - 'git pull --rebase origin main || true', - 'git push || echo "No changes to commit"', - 'git stash --include-untracked || true', - 'cp library/build/outputs/aar/*.aar release-artifacts/ || true', - 'cp library/build/libs/*.jar release-artifacts/ || true', - ], 'KMP release workflow must not call removed tasks or publish wrong coordinates'); - expectIncludes('.github/workflows/release-kmp.yml', [ - 'artifacts=(library/build/outputs/aar/*.aar library/build/libs/*.jar)', - 'No KMP release artifacts found', - 'cp "${artifacts[@]}" release-artifacts/', - ], 'KMP release workflow must fail when release artifacts are missing'); - expectIncludes('.github/workflows/release-maui.yml', [ - '- name: Create and push tag', - 'if: success()', - 'git tag -a "maui-iap-$VERSION" -m "Release maui-iap $VERSION"', - ], 'MAUI release workflow must create tags before GitHub Release creation'); - expectNotIncludes('.github/workflows/release-maui.yml', [ - 'create_release:', - 'inputs.create_release', - ], 'MAUI release workflow must always create GitHub releases'); - expectNotIncludes('packages/docs/src/pages/docs/setup/kmp.tsx', [ - 'io.github.hyochan.kmpiap/library', - ], 'docs KMP Maven Central link must not use old coordinates'); - expectNotIncludes('scripts/agent/compile-context.ts', [ - 'flutter_inapp_purchase: ^5.0.0', - 'io.github.hyochan.kmpiap:library', - 'implementation("io.github.hyochan:kmp-iap:")', - 'OpenIap.Maui" Version="1.0.1', - ], 'AI context compiler framework package versions must not hardcode stale coordinates'); + expectIncludes( + "libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap", + [ + `org.jetbrains.kotlinx:kotlinx-coroutines-android:${googleCoroutinesVersion}`, + ], + "Godot Android GDAP coroutines dependency version", + ); + expectIncludes( + "libraries/godot-iap/scripts/write-gdap.sh", + [ + "set -euo pipefail", + "GOOGLE_OPENIAP_BUILD=", + "read_openiap_version()", + 'python3 - "$VERSIONS_FILE" "$1"', + 'OPENIAP_GOOGLE_VERSION="$(read_openiap_version google)"', + "COROUTINES_VERSION=", + "read_google_variable coroutinesVersion", + "fallback_property kotlinxCoroutinesVersion", + 'remote=["io.github.hyochan.openiap:openiap-google:$OPENIAP_GOOGLE_VERSION", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$COROUTINES_VERSION"]', + ], + "Godot GDAP dependency writer", + ); + expectIncludes( + "libraries/godot-iap/scripts/sync-versions.sh", + [ + "set -euo pipefail", + "GOOGLE_OPENIAP_BUILD=", + "read_openiap_version()", + 'python3 - "$VERSIONS_FILE" "$1"', + "read_google_variable coroutinesVersion", + "fallback_property kotlinxCoroutinesVersion", + ], + "Godot version sync must read coroutines from packages/google when available", + ); + expectIncludes( + "libraries/godot-iap/scripts/build_android.sh", + ["set -euo pipefail"], + "Godot Android build script must fail on unset vars and pipeline failures", + ); + expectIncludes( + "libraries/godot-iap/Makefile", + ["scripts/write-gdap.sh"], + "Godot Makefile must use shared GDAP writer", + ); + expectIncludes( + "libraries/godot-iap/Makefile", + [ + "gh release list --repo hyodotdev/openiap", + 'startswith("godot-iap-")', + "RELEASE_VERSION = $(patsubst godot-iap-%,%,$(RELEASE_TAG))", + "https://github.com/hyodotdev/openiap/releases/download/$(RELEASE_TAG)/$(RELEASE_ZIP_NAME)", + ], + "Godot Makefile release test target must use monorepo release assets", + ); + expectNotIncludes( + "libraries/godot-iap/Makefile", + [ + "hyochan/godot-iap", + "github.com/hyochan/godot-iap/releases", + "RELEASE_ZIP_NAME = godot-iap-$(RELEASE_TAG).zip", + ], + "Godot Makefile release test target must not use standalone release assets", + ); + expectIncludes( + ".github/workflows/release-godot.yml", + [ + "./scripts/write-gdap.sh dist/addons/godot-iap/android/GodotIap.gdap", + "https://openiap.dev/docs/setup/godot", + "https://openiap.dev/docs/apis", + "https://openiap.dev/docs/updates/releases", + ], + "Godot release workflow must use shared GDAP writer", + ); + expectNotIncludes( + ".github/workflows/release-godot.yml", + ["https://www.openiap.dev"], + "Godot release workflow docs links must use canonical OpenIAP URLs", + ); + } + expectIncludes( + "libraries/kmp-iap/native/InAppPurchaseBridge/Package.swift", + [ + "resolveOpenIapAppleVersion()", + "resolveOpenIapApplePackageVersion", + "openiap-versions.json", + "openIapApplePackageVersion", + ], + "KMP native bridge OpenIAP Apple dependency version", + ); + expectNotIncludes( + "libraries/kmp-iap/native/InAppPurchaseBridge/Package.swift", + ['from: "1.2.5"', 'return "2.1.9"', "Version(2, 1, 9)"], + "KMP native bridge OpenIAP Apple dependency version", + ); + expectIncludes( + "libraries/kmp-iap/native/InAppPurchaseBridge/Sources/InAppPurchaseBridge/InAppPurchaseBridge.swift", + ["@_exported import OpenIAP"], + "KMP native bridge SwiftPM target source", + ); + expectIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + [ + "val kmpRootDir = projectDir.parentFile", + 'kmpRootDir.resolve("gradle.properties")', + 'kmpRootDir.resolve("openiap-versions.json")', + "missing openiap-versions.json", + "'$key' version missing in openiap-versions.json", + 'openIapVersion("apple")', + 'openIapVersion("google")', + 'project.findProperty("libraryVersion")', + "GenerateKmpIapVersionTask", + "generateKmpIapVersion", + ], + "KMP Gradle native dependency versions", + ); + expectNotIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + [ + '?: "1.2.5"', + '?: "1.2.10"', + "1.0.0-alpha02", + "1.0.0-alpha04", + "openiap-versions.json with kmp-iap version", + "tasks.withType", + "DEBUG:", + "First 50 chars", + ], + "KMP Gradle native dependency versions must not silently fallback", + ); + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt", + ['override fun getVersion(): String = kmpIapVersionString("Android")'], + "KMP Android runtime version", + ); + expectIncludes( + "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", + ['override fun getVersion(): String = kmpIapVersionString("iOS")'], + "KMP iOS runtime version", + ); + expectNotIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt", + ["ANDROID_VERSION", "1.0.0-alpha02"], + "KMP Android runtime version must not be stale", + ); + expectNotIncludes( + "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", + ["1.0.0-rc.2"], + "KMP iOS runtime version must not be stale", + ); + expectNotIncludes( + "libraries/kmp-iap/README.md", + ["1.0.0-rc.2", "1.0.0-alpha02"], + "KMP README version examples must not be stale", + ); + expectIncludes( + ".github/workflows/release-kmp.yml", + [ + "Update release metadata", + "Check if release tag already exists", + "Checkout release tag (current version)", + 'git checkout "$RELEASE_TAG"', + "libraryVersion=$ENV{VERSION}", + './scripts/update-readme-version.sh "$VERSION"', + "git pull --rebase origin main", + "git push origin HEAD:main", + "https://repo1.maven.org/maven2/io/github/hyochan/kmp-iap/$VERSION/", + "Unable to verify kmp-iap $VERSION on Maven Central", + "if: steps.check_maven.outputs.exists == 'false'", + "Create and push tag", + 'git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"', + 'git push origin "$RELEASE_TAG"', + "./gradlew :library:assembleRelease --no-daemon --stacktrace", + "files: libraries/kmp-iap/release-artifacts.zip", + 'implementation("io.github.hyochan:kmp-iap:$VERSION")', + "implementation 'io.github.hyochan:kmp-iap:$VERSION'", + "central.sonatype.com/artifact/io.github.hyochan/kmp-iap/$VERSION", + ], + "KMP release workflow metadata sync", + ); + expectNotIncludes( + ".github/workflows/release-kmp.yml", + ["create_release:", "inputs.create_release"], + "KMP release workflow must always create GitHub releases", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/kmp.tsx", + ["https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap"], + "docs KMP Maven Central link", + ); + expectIncludes( + "packages/docs/src/lib/images.ts", + ["documentationUrl: 'https://openiap.dev/docs/setup/kmp'"], + "KMP docs card should point at OpenIAP docs", + ); + expectNotIncludes( + "packages/docs/src/lib/images.ts", + ["documentationUrl: 'https://hyochan.github.io/kmp-iap'"], + "KMP docs card must not point at the legacy standalone docs", + ); + expectIncludes( + "scripts/agent/compile-context.ts", + [ + "function readInstallationVersions()", + "libraries/kmp-iap/gradle.properties", + "libraries/godot-iap/addons/godot-iap/plugin.cfg", + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + "([^<]+)<\\/PackageId>", + "flutter pub add flutter_inapp_purchase", + "io.github.hyochan:kmp-iap:${versions.kmp}", + "godot-iap-${versions.godot}.zip", + "dotnet add package ${versions.mauiPackageId}", + "Current NuGet package version: ${versions.maui}", + "https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap", + ], + "AI context compiler framework package versions", + ); + expectNotIncludes( + "scripts/agent/compile-context.ts", + [ + "flutter_inapp_purchase: ^${versions.flutter}", + "io.github.hyochan:kmp-iap:", + 'OpenIap.Maui" Version="${versions.maui}"', + "dotnet add package OpenIap.Maui", + ], + "AI context compiler install commands must not hardcode framework package versions", + ); + expectNotIncludes( + ".github/workflows/release-kmp.yml", + [ + "updatePodspecDependency", + "updateReadmeVersion", + "spec\\.version\\s+=", + "library/library.podspec gradle.properties", + "io.github.hyochan.openiap:kmp-iap", + "central.sonatype.com/artifact/io.github.hyochan.openiap/kmp-iap", + "git pull --rebase origin main || true", + 'git push || echo "No changes to commit"', + "git stash --include-untracked || true", + "cp library/build/outputs/aar/*.aar release-artifacts/ || true", + "cp library/build/libs/*.jar release-artifacts/ || true", + ], + "KMP release workflow must not call removed tasks or publish wrong coordinates", + ); + expectIncludes( + ".github/workflows/release-kmp.yml", + [ + "artifacts=(library/build/outputs/aar/*.aar library/build/libs/*.jar)", + "No KMP release artifacts found", + 'cp "${artifacts[@]}" release-artifacts/', + ], + "KMP release workflow must fail when release artifacts are missing", + ); + expectIncludes( + ".github/workflows/release-maui.yml", + [ + "- name: Create and push tag", + "if: success()", + 'git tag -a "maui-iap-$VERSION" -m "Release maui-iap $VERSION"', + ], + "MAUI release workflow must create tags before GitHub Release creation", + ); + expectNotIncludes( + ".github/workflows/release-maui.yml", + ["create_release:", "inputs.create_release"], + "MAUI release workflow must always create GitHub releases", + ); + expectNotIncludes( + "packages/docs/src/pages/docs/setup/kmp.tsx", + ["io.github.hyochan.kmpiap/library"], + "docs KMP Maven Central link must not use old coordinates", + ); + expectNotIncludes( + "scripts/agent/compile-context.ts", + [ + "flutter_inapp_purchase: ^5.0.0", + "io.github.hyochan.kmpiap:library", + 'implementation("io.github.hyochan:kmp-iap:")', + 'OpenIap.Maui" Version="1.0.1', + ], + "AI context compiler framework package versions must not hardcode stale coordinates", + ); const kmpReadmeInjectedVersionSnippet = 'implementation("io.github.hyochan:kmp-iap:' + '$ENV{VERSION}")'; - expectIncludes('libraries/kmp-iap/scripts/update-readme-version.sh', [ - 'implementation("io.github.hyochan:kmp-iap:")', - 'https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap', - ], 'KMP README version script'); - expectNotIncludes('libraries/kmp-iap/scripts/update-readme-version.sh', [ - 'perl -0pi', - kmpReadmeInjectedVersionSnippet, - 'sed -i', - 'based on local.properties', - ], 'KMP README version script must not inject release-specific versions'); + expectIncludes( + "libraries/kmp-iap/scripts/update-readme-version.sh", + [ + 'implementation("io.github.hyochan:kmp-iap:")', + "https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap", + ], + "KMP README version script", + ); + expectNotIncludes( + "libraries/kmp-iap/scripts/update-readme-version.sh", + [ + "perl -0pi", + kmpReadmeInjectedVersionSnippet, + "sed -i", + "based on local.properties", + ], + "KMP README version script must not inject release-specific versions", + ); for (const generatedTypeScript of [ - 'libraries/kmp-iap/scripts/generate-types.sh', - 'libraries/godot-iap/scripts/generate-types.sh', + "libraries/kmp-iap/scripts/generate-types.sh", + "libraries/godot-iap/scripts/generate-types.sh", ]) { - expectIncludes(generatedTypeScript, [ - 'json.loads', - 'data.get("spec")', - "Error: 'spec' version missing in openiap-versions.json", - ], `${generatedTypeScript} must parse openiap-versions.json as JSON`); - expectNotIncludes(generatedTypeScript, [ - 'grep \'"spec"\'', - "sed 's/.*: *", - ], `${generatedTypeScript} must not parse JSON with grep/sed`); - } - expectIncludes('libraries/godot-iap/scripts/generate-types.sh', [ - 'ADDON_DIR="$REPO_ROOT/addons/godot-iap"', - 'EXAMPLE_ADDON_DIR="$REPO_ROOT/Example/addons/godot-iap"', - 'cp "$TEMP_DIR/types.gd" "$ADDON_DIR/types.gd"', - 'cp "$TEMP_DIR/types.gd" "$EXAMPLE_ADDON_DIR/types.gd"', - ], 'Godot generated types script must update the shipped addon and example'); - expectIncludes('libraries/kmp-iap/publish-local.sh', [ - "grep '^libraryVersion=' gradle.properties", - ], 'KMP publish-local version lookup'); - expectNotIncludes('libraries/kmp-iap/publish-local.sh', [ - "grep 'version = ' library/build.gradle.kts", - ], 'KMP publish-local version lookup must not read removed literal version'); - expectIncludes('libraries/kmp-iap/library/library.podspec', [ - 'missing openiap-versions.json', - "'apple' version missing in openiap-versions.json", - ], 'KMP podspec OpenIAP Apple dependency version'); - expectNotIncludes('libraries/kmp-iap/library/library.podspec', [ - "openiap_apple_version = '1.2.5'", - 'fallback version', - ], 'KMP podspec OpenIAP Apple dependency version'); - expectIncludes('libraries/kmp-iap/example/iosApp/iosApp.xcodeproj/project.pbxproj', [ - 'OPENIAP_APPLE_VERSION=', - '$OPENIAP_APPLE_VERSION', - ], 'KMP iOS example OpenIAP framework plist version'); - expectNotIncludes('libraries/kmp-iap/example/iosApp/iosApp.xcodeproj/project.pbxproj', [ - '1.2.5', - ], 'KMP iOS example OpenIAP framework plist version'); - expectIncludes('libraries/maui-iap/android/openiap/build.gradle.kts', [ - 'missing openiap-versions.json', - "'google' version missing in openiap-versions.json", - ], 'MAUI Android OpenIAP Google dependency version'); - expectNotIncludes('libraries/maui-iap/android/openiap/build.gradle.kts', [ - '?: "', - "?: '", - ], 'MAUI Android OpenIAP Google dependency version must not silently fallback'); - expectIncludes('packages/google/build.gradle.kts', [ - 'missing openiap-versions.json', - "'google' version missing in openiap-versions.json", - 'id("com.android.library") version "8.13.2"', - 'id("com.android.application") version "8.13.2"', - 'id("com.vanniktech.maven.publish") version "0.35.0"', - ], 'packages/google root OpenIAP version'); - expectIncludes('packages/google/gradle/wrapper/gradle-wrapper.properties', [ - 'gradle-8.13-all.zip', - ], 'packages/google Gradle wrapper must support Vanniktech 0.35.0'); - expectNotIncludes('packages/google/build.gradle.kts', [ - 'GQL_VERSION', - '"gql"', - 'Fallback', - 'id("com.vanniktech.maven.publish") version "0.29.0"', - 'id("com.vanniktech.maven.publish") version "0.34.0"', - 'id("com.vanniktech.maven.publish") version "0.36.0"', - ], 'packages/google root OpenIAP version'); - expectIncludes('packages/google/openiap/build.gradle.kts', [ - 'missing openiap-versions.json', - "'google' version missing in openiap-versions.json", - 'compilerOptions', - 'JvmTarget.JVM_17', - 'val horizonPlatformVersion =', - 'val horizonBillingCompatibilityVersion =', - 'publishToMavenCentral()', - ], 'packages/google module OpenIAP version and Kotlin compiler settings'); - expectNotIncludes('packages/google/openiap/build.gradle.kts', [ - '?: "1.0.0"', - 'kotlinOptions', - 'jvmTarget = "17"', - 'SonatypeHost.CENTRAL_PORTAL', - ], 'packages/google module OpenIAP version and Kotlin compiler settings'); + expectIncludes( + generatedTypeScript, + [ + "json.loads", + 'data.get("spec")', + "Error: 'spec' version missing in openiap-versions.json", + ], + `${generatedTypeScript} must parse openiap-versions.json as JSON`, + ); + expectNotIncludes( + generatedTypeScript, + ["grep '\"spec\"'", "sed 's/.*: *"], + `${generatedTypeScript} must not parse JSON with grep/sed`, + ); + } + expectIncludes( + "libraries/godot-iap/scripts/generate-types.sh", + [ + 'ADDON_DIR="$REPO_ROOT/addons/godot-iap"', + 'EXAMPLE_ADDON_DIR="$REPO_ROOT/Example/addons/godot-iap"', + 'cp "$TEMP_DIR/types.gd" "$ADDON_DIR/types.gd"', + 'cp "$TEMP_DIR/types.gd" "$EXAMPLE_ADDON_DIR/types.gd"', + ], + "Godot generated types script must update the shipped addon and example", + ); + expectIncludes( + "libraries/kmp-iap/publish-local.sh", + ["grep '^libraryVersion=' gradle.properties"], + "KMP publish-local version lookup", + ); + expectNotIncludes( + "libraries/kmp-iap/publish-local.sh", + ["grep 'version = ' library/build.gradle.kts"], + "KMP publish-local version lookup must not read removed literal version", + ); + expectIncludes( + "libraries/kmp-iap/library/library.podspec", + [ + "missing openiap-versions.json", + "'apple' version missing in openiap-versions.json", + ], + "KMP podspec OpenIAP Apple dependency version", + ); + expectNotIncludes( + "libraries/kmp-iap/library/library.podspec", + ["openiap_apple_version = '1.2.5'", "fallback version"], + "KMP podspec OpenIAP Apple dependency version", + ); + expectIncludes( + "libraries/kmp-iap/example/iosApp/iosApp.xcodeproj/project.pbxproj", + ["OPENIAP_APPLE_VERSION=", "$OPENIAP_APPLE_VERSION"], + "KMP iOS example OpenIAP framework plist version", + ); + expectNotIncludes( + "libraries/kmp-iap/example/iosApp/iosApp.xcodeproj/project.pbxproj", + ["1.2.5"], + "KMP iOS example OpenIAP framework plist version", + ); + expectIncludes( + "libraries/maui-iap/android/openiap/build.gradle.kts", + [ + "missing openiap-versions.json", + "'google' version missing in openiap-versions.json", + ], + "MAUI Android OpenIAP Google dependency version", + ); + expectNotIncludes( + "libraries/maui-iap/android/openiap/build.gradle.kts", + ['?: "', "?: '"], + "MAUI Android OpenIAP Google dependency version must not silently fallback", + ); + expectIncludes( + "packages/google/build.gradle.kts", + [ + "missing openiap-versions.json", + "'google' version missing in openiap-versions.json", + 'id("com.android.library") version "8.13.2"', + 'id("com.android.application") version "8.13.2"', + 'id("com.vanniktech.maven.publish") version "0.35.0"', + ], + "packages/google root OpenIAP version", + ); + expectIncludes( + "packages/google/gradle/wrapper/gradle-wrapper.properties", + ["gradle-8.13-all.zip"], + "packages/google Gradle wrapper must support Vanniktech 0.35.0", + ); + expectNotIncludes( + "packages/google/build.gradle.kts", + [ + "GQL_VERSION", + '"gql"', + "Fallback", + 'id("com.vanniktech.maven.publish") version "0.29.0"', + 'id("com.vanniktech.maven.publish") version "0.34.0"', + 'id("com.vanniktech.maven.publish") version "0.36.0"', + ], + "packages/google root OpenIAP version", + ); + expectIncludes( + "packages/google/openiap/build.gradle.kts", + [ + "missing openiap-versions.json", + "'google' version missing in openiap-versions.json", + "compilerOptions", + "JvmTarget.JVM_17", + "val horizonBillingCompatibilityVersion =", + "val horizonPlatformKotlinVersion =", + "val horizonSerializationVersion =", + "publishToMavenCentral()", + ], + "packages/google module OpenIAP version and Kotlin compiler settings", + ); + expectNotIncludes( + "packages/google/openiap/build.gradle.kts", + [ + '?: "1.0.0"', + "kotlinOptions", + 'jvmTarget = "17"', + "SonatypeHost.CENTRAL_PORTAL", + ], + "packages/google module OpenIAP version and Kotlin compiler settings", + ); const googleBillingVersions = uniqueMatches( googleBuildGradle, @@ -2338,7 +4154,7 @@ function checkFrameworkDependencyHygiene() { ); if (googleBillingVersions.length !== 1) { fail( - `packages/google must use one Play Billing version, found: ${googleBillingVersions.join(', ') || '(none)'}`, + `packages/google must use one Play Billing version, found: ${googleBillingVersions.join(", ") || "(none)"}`, ); } const googleGsonVersions = uniqueMatches( @@ -2347,488 +4163,739 @@ function checkFrameworkDependencyHygiene() { ); if (googleGsonVersions.length !== 1) { fail( - `packages/google must use one Gson version, found: ${googleGsonVersions.join(', ') || '(none)'}`, - ); - } - expectIncludes('packages/google/openiap/build.gradle.kts', [ - 'com.android.billingclient:billing-ktx:$playBillingVersion', - ], 'packages/google Play Billing dependency version'); - expectNotIncludes('packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt', [ - 'printStackTrace()', - ], 'Google production Store code must use structured logging'); - expectNotIncludes('packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt', [ - 'printStackTrace()', - 'TODO: In production', - 'alternativeBillingCallback?.onTokenCreated', - ], 'Google Play production module must use structured logging'); - const googleBuildRoot = read('packages/google/build.gradle.kts'); - const googleCompileSdk = googleBuildGradle.match(/compileSdk\s*=\s*(\d+)/)?.[1]; + `packages/google must use one Gson version, found: ${googleGsonVersions.join(", ") || "(none)"}`, + ); + } + expectIncludes( + "packages/google/openiap/build.gradle.kts", + ["com.android.billingclient:billing:$playBillingVersion"], + "packages/google Play Billing dependency version", + ); + expectNotIncludes( + "packages/google/openiap/build.gradle.kts", + ["com.android.billingclient:billing-ktx:"], + "packages/google must not leak billing-ktx Kotlin metadata to consumers", + ); + expectNotIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + ["com.android.billingclient:billing-ktx:"], + "KMP Android compile classpaths must use the Billing core artifact", + ); + expectNotIncludes( + "packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt", + ["printStackTrace()"], + "Google production Store code must use structured logging", + ); + expectNotIncludes( + "packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt", + [ + "printStackTrace()", + "TODO: In production", + "alternativeBillingCallback?.onTokenCreated", + ], + "Google Play production module must use structured logging", + ); + const googleBuildRoot = read("packages/google/build.gradle.kts"); + const googleCompileSdk = googleBuildGradle.match( + /compileSdk\s*=\s*(\d+)/, + )?.[1]; const googleMinSdk = googleBuildGradle.match(/minSdk\s*=\s*(\d+)/)?.[1]; - const googleAndroidGradlePluginVersion = googleBuildRoot.match(/com\.android\.library"\) version "([^"]+)"/)?.[1]; - const googleKotlinVersion = googleBuildRoot.match(/org\.jetbrains\.kotlin\.android"\) version "([^"]+)"/)?.[1]; - if (!googleCompileSdk) fail('packages/google openiap build.gradle.kts must declare compileSdk'); - if (!googleMinSdk) fail('packages/google openiap build.gradle.kts must declare minSdk'); - if (!googleAndroidGradlePluginVersion) fail('packages/google build.gradle.kts must declare Android Gradle plugin version'); - if (!googleKotlinVersion) fail('packages/google build.gradle.kts must declare Kotlin Android plugin version'); - if (googleCompileSdk && googleMinSdk && googleBillingVersions.length === 1 && googleKotlinVersion) { - expectIncludes('packages/google/README.md', [ - `API-${googleMinSdk}%2B`, - `api?level=${googleMinSdk}`, - `**Minimum SDK**: ${googleMinSdk}`, - `**Compile SDK**: ${googleCompileSdk}`, - `**Google Play Billing**: v${googleBillingVersions[0]}`, - `**Kotlin**: ${googleKotlinVersion}+`, - ], 'Google README requirements'); - expectIncludes('packages/google/Example/build.gradle.kts', [ - 'openIapBuildFile', - 'readOpenIapAndroidInt("compileSdk")', - 'readOpenIapAndroidInt("minSdk")', - 'readOpenIapDependencyVersion("androidx.core:core-ktx")', - 'readOpenIapDependencyVersion("androidx.lifecycle:lifecycle-runtime-ktx")', - 'readOpenIapDependencyVersion("androidx.lifecycle:lifecycle-viewmodel-ktx")', - 'readOpenIapDependencyVersion("junit:junit")', - 'compileSdk = openIapCompileSdk', - 'minSdk = openIapMinSdk', - 'targetSdk = openIapTargetSdk', - 'implementation("androidx.core:core-ktx:$openIapCoreKtxVersion")', - 'implementation("androidx.lifecycle:lifecycle-runtime-ktx:$openIapLifecycleRuntimeVersion")', - 'implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$openIapLifecycleViewModelVersion")', - 'testImplementation("junit:junit:$openIapJunitVersion")', - ], 'Google example overlapping Android versions must derive from openiap module'); - expectNotIncludes('packages/google/Example/build.gradle.kts', [ - 'compileSdk = 35', - 'minSdk = 24', - 'targetSdk = 35', - 'androidx.core:core-ktx:1.13.1', - 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7', - 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7', - 'junit:junit:4.13.2', - ], 'Google example overlapping Android versions must not drift from openiap module'); - expectIncludes('libraries/expo-iap/android/build.gradle', [ - 'openiap-android-sdk.gradle', - 'resolvePackageJsonFile()', - 'expoIapPackageVersion', - 'version = expoIapPackageVersion', - 'versionName = expoIapPackageVersion', - `openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', ${googleCompileSdk})`, - `openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', ${googleMinSdk})`, - `openIapResolveAndroidSdkVersion('targetSdkVersion', 'compileSdk', ${googleCompileSdk})`, - 'compileSdk = openIapCompileSdkVersion', - 'minSdk = openIapMinSdkVersion', - 'targetSdk = openIapTargetSdkVersion', - ], 'Expo Android SDK versions must follow openiap-google'); - expectIncludes('libraries/expo-iap/android/openiap-android-sdk.gradle', [ - 'packages/google/openiap/build.gradle.kts', - 'openIapResolveAndroidSdkVersion', - 'rootProject.ext.has(extName)', - ], 'Expo Android SDK versions must derive from openiap-google when available'); - expectNotIncludes('libraries/expo-iap/android/build.gradle', [ - 'safeExtGet("compileSdkVersion", 34)', - 'safeExtGet("targetSdkVersion", 34)', - 'compileSdkVersion openIapCompileSdkVersion', - 'minSdkVersion openIapMinSdkVersion', - 'targetSdkVersion openIapTargetSdkVersion', - 'namespace "expo.modules.iap"', - 'abortOnError false', - 'versionCode 1', - 'versionName "0.1.0"', - "version = '0.1.0'", - 'versionName = "0.1.0"', - ], 'Expo Android SDK fallbacks must not lag openiap-google'); - expectOptionalNotIncludes('libraries/expo-iap/example/android/build.gradle', [ - "url 'https://www.jitpack.io'", - ], 'Expo example root Gradle must avoid deprecated Groovy property syntax'); - expectOptionalIncludes('libraries/expo-iap/example/android/app/build.gradle', [ - 'compileSdk = rootProject.ext.compileSdkVersion', - 'minSdk = rootProject.ext.minSdkVersion', - 'targetSdk = rootProject.ext.targetSdkVersion', - 'namespace =', - ], 'Expo example app Gradle SDK versions must use assignment syntax'); - expectOptionalNotIncludes('libraries/expo-iap/example/android/app/build.gradle', [ - 'ndkVersion rootProject.ext.ndkVersion', - 'buildToolsVersion rootProject.ext.buildToolsVersion', - 'compileSdk rootProject.ext.compileSdkVersion', - "namespace 'dev.hyo.martie'", - "applicationId 'dev.hyo.martie'", - 'minSdkVersion rootProject.ext.minSdkVersion', - 'targetSdkVersion rootProject.ext.targetSdkVersion', - 'signingConfig signingConfigs.debug', - 'shrinkResources enableShrinkResources.toBoolean()', - 'crunchPngs enablePngCrunchInRelease.toBoolean()', - 'useLegacyPackaging enableLegacyPackaging.toBoolean()', - "ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'", - ], 'Expo example app Gradle must avoid deprecated Groovy property syntax'); - expectIncludes('libraries/flutter_inapp_purchase/android/build.gradle', [ - 'locateGoogleRootBuildFile', - 'readPubspecVersion', - 'flutterPackageVersion', - 'version = flutterPackageVersion', - "googlePluginVersion('com.android.library')", - 'openIapAndroidGradlePluginVersion', - 'openIapKotlinVersion', - 'readRequiredAndroidGradleProperty', - "readRequiredAndroidGradleProperty(projectDir, 'openIapAndroidAnnotationVersion')", - 'classpath "com.android.tools.build:gradle:$androidGradlePluginVersion"', - 'openiap-android-sdk.gradle', - `openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', ${googleCompileSdk})`, - `openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', ${googleMinSdk})`, - `openIapResolveAndroidSdkVersion('targetSdkVersion', 'compileSdk', ${googleCompileSdk})`, - 'compileSdk = openIapCompileSdkVersion', - 'minSdkVersion = openIapMinSdkVersion', - 'targetSdkVersion = openIapTargetSdkVersion', - ], 'Flutter Android minSdk must follow openiap-google'); - expectNotIncludes('libraries/flutter_inapp_purchase/android/build.gradle', [ - "namespace 'io.github.hyochan.flutter_inapp_purchase'", - 'compileSdkVersion openIapCompileSdkVersion', - 'minSdkVersion openIapMinSdkVersion', - 'targetSdkVersion openIapTargetSdkVersion', - 'com.android.tools.build:gradle:8.7.3', - "implementation 'androidx.annotation:annotation:1.6.0'", - "implementation files('jars/in-app-purchasing-2.0.76.jar')", - 'openIapAmazonIapJarFile', - "version = '1.0-SNAPSHOT'", - ], 'Flutter Android Gradle must avoid deprecated Groovy property syntax'); - expectIncludes('libraries/flutter_inapp_purchase/example/android/app/build.gradle', [ - 'openiap-android-sdk.gradle', - 'compileSdk = openIapCompileSdkVersion', - 'minSdkVersion = openIapMinSdkVersion', - 'targetSdkVersion = openIapTargetSdkVersion', - "openIapResolveDependencyVersion('junit:junit', 'openIapJunitVersion')", - "openIapResolveDependencyVersion('androidx.test:runner', 'openIapAndroidTestRunnerVersion')", - "openIapResolveDependencyVersion('androidx.test.espresso:espresso-core', 'openIapEspressoCoreVersion')", - 'testImplementation "junit:junit:$openIapJunitVersion"', - 'androidTestImplementation "androidx.test:runner:$openIapAndroidTestRunnerVersion"', - 'androidTestImplementation "androidx.test.espresso:espresso-core:$openIapEspressoCoreVersion"', - "source = '../..'", - ], 'Flutter example Android minSdk must follow openiap-google'); - expectNotIncludes('libraries/flutter_inapp_purchase/example/android/app/build.gradle', [ - "namespace 'dev.hyo.martie'", - 'compileSdkVersion openIapCompileSdkVersion', - 'ndkVersion "27.0.12077973"', - 'applicationId "dev.hyo.martie"', - 'minSdkVersion openIapMinSdkVersion', - 'targetSdkVersion openIapTargetSdkVersion', - "testImplementation 'junit:junit:4.13.2'", - "androidTestImplementation 'androidx.test:runner:1.5.2'", - "androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'", - 'signingConfig signingConfigs.debug', - "source '../..'", - ], 'Flutter example Android Gradle must avoid deprecated Groovy property syntax'); - expectIncludes('libraries/flutter_inapp_purchase/example/android/build.gradle', [ - 'locateGoogleRootBuildFile', - "googlePluginVersion('com.android.application')", - 'openIapAndroidGradlePluginVersion', - 'classpath "com.android.tools.build:gradle:$androidGradlePluginVersion"', - ], 'Flutter example root Gradle plugin version must derive from packages/google'); - expectIncludes('libraries/flutter_inapp_purchase/example/android/settings.gradle', [ - 'resolutionStrategy', - 'useVersion(androidGradlePluginVersion)', - 'useVersion(kotlinPluginVersion)', - 'id "com.android.application" apply false', - 'id "org.jetbrains.kotlin.android" apply false', - ], 'Flutter example Android Gradle plugin version must derive from packages/google'); - expectIncludes('libraries/flutter_inapp_purchase/android/gradle.properties', [ - `openIapAndroidGradlePluginVersion=${googleAndroidGradlePluginVersion}`, - `openIapKotlinVersion=${googleKotlinVersion}`, - 'openIapAndroidAnnotationVersion=', - 'openIapJunitVersion=', - ], 'Flutter Android Gradle plugin fallback versions'); - expectIncludes('libraries/flutter_inapp_purchase/example/android/gradle.properties', [ - `openIapAndroidGradlePluginVersion=${googleAndroidGradlePluginVersion}`, - `openIapKotlinVersion=${googleKotlinVersion}`, - 'openIapJunitVersion=', - 'openIapAndroidTestRunnerVersion=', - 'openIapEspressoCoreVersion=', - ], 'Flutter example Android Gradle plugin fallback versions'); - expectIncludes('libraries/flutter_inapp_purchase/android/gradle/wrapper/gradle-wrapper.properties', [ - 'gradle-8.13-bin.zip', - ], 'Flutter standalone Android Gradle wrapper must support packages/google AGP'); - expectIncludes('libraries/flutter_inapp_purchase/example/android/gradle/wrapper/gradle-wrapper.properties', [ - 'gradle-8.13-all.zip', - ], 'Flutter example Android Gradle wrapper must support packages/google AGP'); - expectIncludes('libraries/godot-iap/android/settings.gradle.kts', [ - 'googlePluginVersion(', - 'googleRootBuildFile', - 'configuredVersion(fallbackPropertyName)', - ], 'Godot Android Gradle plugin versions must derive from packages/google when available'); - expectIncludes('libraries/godot-iap/android/gradle.properties', [ - `androidGradlePluginVersion=${googleAndroidGradlePluginVersion}`, - `kotlinVersion=${googleKotlinVersion}`, - ], 'Godot Android Gradle plugin fallback versions'); - expectIncludes('libraries/godot-iap/android/gradle/wrapper/gradle-wrapper.properties', [ - 'gradle-8.13-bin.zip', - ], 'Godot Android Gradle wrapper must support packages/google AGP'); - expectIncludes('libraries/godot-iap/android/build.gradle.kts', [ - 'googleOpenIapBuildFile', - 'readGoogleAndroidInt("compileSdk", "compileSdkVersion")', - 'readGoogleAndroidInt("minSdk", "minSdkVersion")', - 'readGoogleVariable("coroutinesVersion", "kotlinxCoroutinesVersion")', - 'compileSdk = googleCompileSdk', - 'minSdk = googleMinSdk', - 'implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$googleCoroutinesVersion")', - ], 'Godot Android SDK versions must derive from packages/google when available'); - expectNotIncludes('libraries/godot-iap/android/build.gradle.kts', [ - 'compileSdk = 35', - 'minSdk = 24', - 'val kotlinxCoroutinesVersion: String by project', - 'kotlinx-coroutines-android:$kotlinxCoroutinesVersion', - ], 'Godot Android SDK versions must not drift from packages/google'); - expectNotIncludes('libraries/godot-iap/android/settings.gradle.kts', [ - 'id("com.android.library") version "8.7.2"', - 'id("org.jetbrains.kotlin.android") version "2.2.0"', - ], 'Godot Android Gradle plugin versions must not drift from packages/google'); - expectIncludes('libraries/flutter_inapp_purchase/android/openiap-android-sdk.gradle', [ - 'openIapFindGoogleOpenIapBuildFile', - 'packages/google/openiap/build.gradle.kts', - 'openIapResolveAndroidSdkVersion', - 'openIapResolveDependencyVersion', - 'openIapReadGoogleDependencyVersion', - 'rootProject.ext.has(extName)', - ], 'Flutter Android SDK versions must derive from openiap-google when available'); - expectNotIncludes('libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml', [ - 'package="dev.hyo.martie"', - ], 'Flutter example Android namespace must live in Gradle'); - expectNotIncludes('libraries/flutter_inapp_purchase/android/build.gradle', [ - 'com.android.tools.build:gradle:8.1.4', - ], 'Flutter standalone Android Gradle plugin must not lag compileSdk support'); - expectIncludes('libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt', [ - '@Suppress("DEPRECATION")', - 'val isAvailable = iap.checkAlternativeBillingAvailability()', - 'val token = iap.createAlternativeBillingReportingToken()', - 'val payload = JSONObject(e.toJSON())', - ], 'Flutter Android plugin must preserve legacy alternative billing handlers'); - expectNotIncludes('libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt', [ - '@Deprecated("Deprecated channel endpoint; will be removed in 7.0.0")', - 'will be removed in 7.0.0', - 'removed in 7.0.0', - 'when (e)', - ], 'Flutter Android plugin must not reintroduce avoidable Kotlin warnings'); - expectNotIncludes('libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt', [ - 'AmazonInappPurchasePlugin', - 'com.amazon.venezia', - 'isAppInstalledFrom', - ], 'Flutter Android plugin must not expose standalone Amazon support'); - expectIncludes('libraries/react-native-iap/android/gradle.properties', [ - `NitroIap_minSdkVersion=${googleMinSdk}`, - ], 'React Native Android minSdk must follow openiap-google'); - expectIncludes('packages/docs/src/lib/versioning.ts', [ - 'googleCompileSdk', - 'googleMinSdk', - 'googlePlayBillingVersion', - 'export const ANDROID_SDK', - 'export const GOOGLE_PLAY_BILLING', - ], 'docs Android SDK metadata must derive from generated docs metadata'); - expectIncludes('packages/docs/src/pages/docs/setup/expo.tsx', [ - 'ANDROID_SDK', - 'GOOGLE_PLAY_BILLING', - 'GOOGLE_PLAY_BILLING.version', - 'minSdkVersion {ANDROID_SDK.minSdk}+', - 'compileSdkVersion {ANDROID_SDK.compileSdk}+', - ], 'Expo setup Android requirements'); - expectIncludes('packages/docs/src/pages/docs/setup/flutter.tsx', [ - 'ANDROID_SDK', - 'minSdkVersion ${ANDROID_SDK.minSdk}', - 'minSdk = ${ANDROID_SDK.minSdk}', - 'compileSdkVersion ${ANDROID_SDK.compileSdk}', - 'compileSdk = ${ANDROID_SDK.compileSdk}', - 'targetSdkVersion ${ANDROID_SDK.targetSdk}', - 'targetSdk = ${ANDROID_SDK.targetSdk}', - ], 'Flutter setup Android requirements'); - expectIncludes('packages/docs/src/pages/docs/setup/react-native.tsx', [ - 'ANDROID_SDK', - 'GOOGLE_PLAY_BILLING', - 'GOOGLE_PLAY_BILLING.version', - 'minSdkVersion {ANDROID_SDK.minSdk}+', - 'compileSdkVersion {ANDROID_SDK.compileSdk}+', - ], 'React Native setup Android requirements'); - expectIncludes('packages/docs/src/pages/docs/setup/store/horizon.tsx', [ - 'OPENIAP_VERSIONS', - 'OPENIAP_VERSIONS.google', - ], 'Horizon setup docs must use current openiap-google version metadata'); - expectNotIncludes('packages/docs/src/pages/docs/setup/store/horizon.tsx', [ - 'openiap-google@1.3.2', - ], 'Horizon setup docs must not hardcode stale openiap-google versions'); - expectNotIncludes('packages/docs/src/pages/docs/setup/react-native.tsx', [ - 'Google Play Billing 8.0+', - 'compileSdkVersion 34+', - ], 'React Native setup Android requirements must not use stale Google requirements'); + const googleAndroidGradlePluginVersion = googleBuildRoot.match( + /com\.android\.library"\) version "([^"]+)"/, + )?.[1]; + const googleKotlinVersion = googleBuildRoot.match( + /org\.jetbrains\.kotlin\.android"\) version "([^"]+)"/, + )?.[1]; + if (!googleCompileSdk) + fail("packages/google openiap build.gradle.kts must declare compileSdk"); + if (!googleMinSdk) + fail("packages/google openiap build.gradle.kts must declare minSdk"); + if (!googleAndroidGradlePluginVersion) + fail( + "packages/google build.gradle.kts must declare Android Gradle plugin version", + ); + if (!googleKotlinVersion) + fail( + "packages/google build.gradle.kts must declare Kotlin Android plugin version", + ); + if ( + googleCompileSdk && + googleMinSdk && + googleBillingVersions.length === 1 && + googleKotlinVersion + ) { + expectIncludes( + "packages/google/README.md", + [ + `API-${googleMinSdk}%2B`, + `api?level=${googleMinSdk}`, + `**Minimum SDK**: ${googleMinSdk}`, + `**Compile SDK**: ${googleCompileSdk}`, + `**Google Play Billing**: v${googleBillingVersions[0]}`, + `**Kotlin**: ${googleKotlinVersion}+`, + ], + "Google README requirements", + ); + expectIncludes( + "packages/google/Example/build.gradle.kts", + [ + "openIapBuildFile", + 'readOpenIapAndroidInt("compileSdk")', + 'readOpenIapAndroidInt("minSdk")', + 'readOpenIapDependencyVersion("androidx.core:core-ktx")', + 'readOpenIapDependencyVersion("androidx.lifecycle:lifecycle-runtime-ktx")', + 'readOpenIapDependencyVersion("androidx.lifecycle:lifecycle-viewmodel-ktx")', + 'readOpenIapDependencyVersion("junit:junit")', + "compileSdk = openIapCompileSdk", + "minSdk = openIapMinSdk", + "targetSdk = openIapTargetSdk", + 'implementation("androidx.core:core-ktx:$openIapCoreKtxVersion")', + 'implementation("androidx.lifecycle:lifecycle-runtime-ktx:$openIapLifecycleRuntimeVersion")', + 'implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$openIapLifecycleViewModelVersion")', + 'testImplementation("junit:junit:$openIapJunitVersion")', + ], + "Google example overlapping Android versions must derive from openiap module", + ); + expectNotIncludes( + "packages/google/Example/build.gradle.kts", + [ + "compileSdk = 35", + "minSdk = 24", + "targetSdk = 35", + "androidx.core:core-ktx:1.13.1", + "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7", + "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7", + "junit:junit:4.13.2", + ], + "Google example overlapping Android versions must not drift from openiap module", + ); + expectIncludes( + "libraries/expo-iap/android/build.gradle", + [ + "openiap-android-sdk.gradle", + "resolvePackageJsonFile()", + "expoIapPackageVersion", + "version = expoIapPackageVersion", + "versionName = expoIapPackageVersion", + `openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', ${googleCompileSdk})`, + `openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', ${googleMinSdk})`, + `openIapResolveAndroidSdkVersion('targetSdkVersion', 'compileSdk', ${googleCompileSdk})`, + "compileSdk = openIapCompileSdkVersion", + "minSdk = openIapMinSdkVersion", + "targetSdk = openIapTargetSdkVersion", + ], + "Expo Android SDK versions must follow openiap-google", + ); + expectIncludes( + "libraries/expo-iap/android/openiap-android-sdk.gradle", + [ + "packages/google/openiap/build.gradle.kts", + "openIapResolveAndroidSdkVersion", + "rootProject.ext.has(extName)", + ], + "Expo Android SDK versions must derive from openiap-google when available", + ); + expectNotIncludes( + "libraries/expo-iap/android/build.gradle", + [ + 'safeExtGet("compileSdkVersion", 34)', + 'safeExtGet("targetSdkVersion", 34)', + "compileSdkVersion openIapCompileSdkVersion", + "minSdkVersion openIapMinSdkVersion", + "targetSdkVersion openIapTargetSdkVersion", + 'namespace "expo.modules.iap"', + "abortOnError false", + "versionCode 1", + 'versionName "0.1.0"', + "version = '0.1.0'", + 'versionName = "0.1.0"', + ], + "Expo Android SDK fallbacks must not lag openiap-google", + ); + expectOptionalNotIncludes( + "libraries/expo-iap/example/android/build.gradle", + ["url 'https://www.jitpack.io'"], + "Expo example root Gradle must avoid deprecated Groovy property syntax", + ); + expectOptionalIncludes( + "libraries/expo-iap/example/android/app/build.gradle", + [ + "compileSdk = rootProject.ext.compileSdkVersion", + "minSdk = rootProject.ext.minSdkVersion", + "targetSdk = rootProject.ext.targetSdkVersion", + "namespace =", + ], + "Expo example app Gradle SDK versions must use assignment syntax", + ); + expectOptionalNotIncludes( + "libraries/expo-iap/example/android/app/build.gradle", + [ + "ndkVersion rootProject.ext.ndkVersion", + "buildToolsVersion rootProject.ext.buildToolsVersion", + "compileSdk rootProject.ext.compileSdkVersion", + "namespace 'dev.hyo.martie'", + "applicationId 'dev.hyo.martie'", + "minSdkVersion rootProject.ext.minSdkVersion", + "targetSdkVersion rootProject.ext.targetSdkVersion", + "signingConfig signingConfigs.debug", + "shrinkResources enableShrinkResources.toBoolean()", + "crunchPngs enablePngCrunchInRelease.toBoolean()", + "useLegacyPackaging enableLegacyPackaging.toBoolean()", + "ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'", + ], + "Expo example app Gradle must avoid deprecated Groovy property syntax", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/build.gradle", + [ + "locateGoogleRootBuildFile", + "readPubspecVersion", + "flutterPackageVersion", + "version = flutterPackageVersion", + "googlePluginVersion('com.android.library')", + "openIapAndroidGradlePluginVersion", + "openIapKotlinVersion", + "readRequiredAndroidGradleProperty", + "readRequiredAndroidGradleProperty(projectDir, 'openIapAndroidAnnotationVersion')", + 'classpath "com.android.tools.build:gradle:$androidGradlePluginVersion"', + "openiap-android-sdk.gradle", + `openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', ${googleCompileSdk})`, + `openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', ${googleMinSdk})`, + `openIapResolveAndroidSdkVersion('targetSdkVersion', 'compileSdk', ${googleCompileSdk})`, + "compileSdk = openIapCompileSdkVersion", + "minSdkVersion = openIapMinSdkVersion", + "targetSdkVersion = openIapTargetSdkVersion", + ], + "Flutter Android minSdk must follow openiap-google", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/android/build.gradle", + [ + "namespace 'io.github.hyochan.flutter_inapp_purchase'", + "compileSdkVersion openIapCompileSdkVersion", + "minSdkVersion openIapMinSdkVersion", + "targetSdkVersion openIapTargetSdkVersion", + "com.android.tools.build:gradle:8.7.3", + "implementation 'androidx.annotation:annotation:1.6.0'", + "implementation files('jars/in-app-purchasing-2.0.76.jar')", + "openIapAmazonIapJarFile", + "version = '1.0-SNAPSHOT'", + ], + "Flutter Android Gradle must avoid deprecated Groovy property syntax", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/example/android/app/build.gradle", + [ + "openiap-android-sdk.gradle", + "compileSdk = openIapCompileSdkVersion", + "minSdkVersion = openIapMinSdkVersion", + "targetSdkVersion = openIapTargetSdkVersion", + "openIapResolveDependencyVersion('junit:junit', 'openIapJunitVersion')", + "openIapResolveDependencyVersion('androidx.test:runner', 'openIapAndroidTestRunnerVersion')", + "openIapResolveDependencyVersion('androidx.test.espresso:espresso-core', 'openIapEspressoCoreVersion')", + 'testImplementation "junit:junit:$openIapJunitVersion"', + 'androidTestImplementation "androidx.test:runner:$openIapAndroidTestRunnerVersion"', + 'androidTestImplementation "androidx.test.espresso:espresso-core:$openIapEspressoCoreVersion"', + "source = '../..'", + ], + "Flutter example Android minSdk must follow openiap-google", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/example/android/app/build.gradle", + [ + "namespace 'dev.hyo.martie'", + "compileSdkVersion openIapCompileSdkVersion", + 'ndkVersion "27.0.12077973"', + 'applicationId "dev.hyo.martie"', + "minSdkVersion openIapMinSdkVersion", + "targetSdkVersion openIapTargetSdkVersion", + "testImplementation 'junit:junit:4.13.2'", + "androidTestImplementation 'androidx.test:runner:1.5.2'", + "androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'", + "signingConfig signingConfigs.debug", + "source '../..'", + ], + "Flutter example Android Gradle must avoid deprecated Groovy property syntax", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/example/android/build.gradle", + [ + "locateGoogleRootBuildFile", + "googlePluginVersion('com.android.application')", + "openIapAndroidGradlePluginVersion", + 'classpath "com.android.tools.build:gradle:$androidGradlePluginVersion"', + ], + "Flutter example root Gradle plugin version must derive from packages/google", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/example/android/settings.gradle", + [ + "resolutionStrategy", + "useVersion(androidGradlePluginVersion)", + "useVersion(kotlinPluginVersion)", + 'id "com.android.application" apply false', + 'id "org.jetbrains.kotlin.android" apply false', + ], + "Flutter example Android Gradle plugin version must derive from packages/google", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/gradle.properties", + [ + `openIapAndroidGradlePluginVersion=${googleAndroidGradlePluginVersion}`, + `openIapKotlinVersion=${googleKotlinVersion}`, + "openIapAndroidAnnotationVersion=", + "openIapJunitVersion=", + ], + "Flutter Android Gradle plugin fallback versions", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/example/android/gradle.properties", + [ + `openIapAndroidGradlePluginVersion=${googleAndroidGradlePluginVersion}`, + `openIapKotlinVersion=${googleKotlinVersion}`, + "openIapJunitVersion=", + "openIapAndroidTestRunnerVersion=", + "openIapEspressoCoreVersion=", + ], + "Flutter example Android Gradle plugin fallback versions", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/gradle/wrapper/gradle-wrapper.properties", + ["gradle-8.13-bin.zip"], + "Flutter standalone Android Gradle wrapper must support packages/google AGP", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/example/android/gradle/wrapper/gradle-wrapper.properties", + ["gradle-8.13-all.zip"], + "Flutter example Android Gradle wrapper must support packages/google AGP", + ); + expectIncludes( + "libraries/godot-iap/android/settings.gradle.kts", + [ + "googlePluginVersion(", + "googleRootBuildFile", + "configuredVersion(fallbackPropertyName)", + ], + "Godot Android Gradle plugin versions must derive from packages/google when available", + ); + expectIncludes( + "libraries/godot-iap/android/gradle.properties", + [ + `androidGradlePluginVersion=${googleAndroidGradlePluginVersion}`, + `kotlinVersion=${googleKotlinVersion}`, + ], + "Godot Android Gradle plugin fallback versions", + ); + expectIncludes( + "libraries/godot-iap/android/gradle/wrapper/gradle-wrapper.properties", + ["gradle-8.13-bin.zip"], + "Godot Android Gradle wrapper must support packages/google AGP", + ); + expectIncludes( + "libraries/godot-iap/android/build.gradle.kts", + [ + "googleOpenIapBuildFile", + 'readGoogleAndroidInt("compileSdk", "compileSdkVersion")', + 'readGoogleAndroidInt("minSdk", "minSdkVersion")', + 'readGoogleVariable("coroutinesVersion", "kotlinxCoroutinesVersion")', + "compileSdk = googleCompileSdk", + "minSdk = googleMinSdk", + 'implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$googleCoroutinesVersion")', + ], + "Godot Android SDK versions must derive from packages/google when available", + ); + expectNotIncludes( + "libraries/godot-iap/android/build.gradle.kts", + [ + "compileSdk = 35", + "minSdk = 24", + "val kotlinxCoroutinesVersion: String by project", + "kotlinx-coroutines-android:$kotlinxCoroutinesVersion", + ], + "Godot Android SDK versions must not drift from packages/google", + ); + expectNotIncludes( + "libraries/godot-iap/android/settings.gradle.kts", + [ + 'id("com.android.library") version "8.7.2"', + 'id("org.jetbrains.kotlin.android") version "2.2.0"', + ], + "Godot Android Gradle plugin versions must not drift from packages/google", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/openiap-android-sdk.gradle", + [ + "openIapFindGoogleOpenIapBuildFile", + "packages/google/openiap/build.gradle.kts", + "openIapResolveAndroidSdkVersion", + "openIapResolveDependencyVersion", + "openIapReadGoogleDependencyVersion", + "rootProject.ext.has(extName)", + ], + "Flutter Android SDK versions must derive from openiap-google when available", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml", + ['package="dev.hyo.martie"'], + "Flutter example Android namespace must live in Gradle", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/android/build.gradle", + ["com.android.tools.build:gradle:8.1.4"], + "Flutter standalone Android Gradle plugin must not lag compileSdk support", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt", + [ + '@Suppress("DEPRECATION")', + "val isAvailable = iap.checkAlternativeBillingAvailability()", + "val token = iap.createAlternativeBillingReportingToken()", + "val payload = JSONObject(e.toJSON())", + ], + "Flutter Android plugin must preserve legacy alternative billing handlers", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt", + [ + '@Deprecated("Deprecated channel endpoint; will be removed in 7.0.0")', + "will be removed in 7.0.0", + "removed in 7.0.0", + "when (e)", + ], + "Flutter Android plugin must not reintroduce avoidable Kotlin warnings", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt", + ["AmazonInappPurchasePlugin", "com.amazon.venezia", "isAppInstalledFrom"], + "Flutter Android plugin must not expose standalone Amazon support", + ); + expectIncludes( + "libraries/react-native-iap/android/gradle.properties", + [`NitroIap_minSdkVersion=${googleMinSdk}`], + "React Native Android minSdk must follow openiap-google", + ); + expectIncludes( + "packages/docs/src/lib/versioning.ts", + [ + "googleCompileSdk", + "googleMinSdk", + "googlePlayBillingVersion", + "export const ANDROID_SDK", + "export const GOOGLE_PLAY_BILLING", + ], + "docs Android SDK metadata must derive from generated docs metadata", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/expo.tsx", + [ + "ANDROID_SDK", + "GOOGLE_PLAY_BILLING", + "GOOGLE_PLAY_BILLING.version", + "minSdkVersion {ANDROID_SDK.minSdk}+", + "compileSdkVersion {ANDROID_SDK.compileSdk}+", + ], + "Expo setup Android requirements", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/flutter.tsx", + [ + "ANDROID_SDK", + "minSdkVersion ${ANDROID_SDK.minSdk}", + "minSdk = ${ANDROID_SDK.minSdk}", + "compileSdkVersion ${ANDROID_SDK.compileSdk}", + "compileSdk = ${ANDROID_SDK.compileSdk}", + "targetSdkVersion ${ANDROID_SDK.targetSdk}", + "targetSdk = ${ANDROID_SDK.targetSdk}", + ], + "Flutter setup Android requirements", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/react-native.tsx", + [ + "ANDROID_SDK", + "GOOGLE_PLAY_BILLING", + "GOOGLE_PLAY_BILLING.version", + "minSdkVersion {ANDROID_SDK.minSdk}+", + "compileSdkVersion {ANDROID_SDK.compileSdk}+", + ], + "React Native setup Android requirements", + ); + expectIncludes( + "packages/docs/src/pages/docs/setup/store/horizon.tsx", + ["OPENIAP_VERSIONS", "OPENIAP_VERSIONS.google"], + "Horizon setup docs must use current openiap-google version metadata", + ); + expectNotIncludes( + "packages/docs/src/pages/docs/setup/store/horizon.tsx", + ["openiap-google@1.3.2"], + "Horizon setup docs must not hardcode stale openiap-google versions", + ); + expectNotIncludes( + "packages/docs/src/pages/docs/setup/react-native.tsx", + ["Google Play Billing 8.0+", "compileSdkVersion 34+"], + "React Native setup Android requirements must not use stale Google requirements", + ); for (const docsSetupFile of [ - 'packages/docs/src/pages/docs/setup/expo.tsx', - 'packages/docs/src/pages/docs/setup/flutter.tsx', - 'packages/docs/src/pages/docs/setup/react-native.tsx', + "packages/docs/src/pages/docs/setup/expo.tsx", + "packages/docs/src/pages/docs/setup/flutter.tsx", + "packages/docs/src/pages/docs/setup/react-native.tsx", ]) { - expectNotIncludes(docsSetupFile, [ - `Google Play Billing Library v${googleBillingVersions[0]}`, - `Google Play Billing ${googleBillingVersions[0]}+`, - `minSdkVersion ${googleMinSdk}+`, - `compileSdkVersion ${googleCompileSdk}+`, - `minSdkVersion ${googleMinSdk}`, - `compileSdkVersion ${googleCompileSdk}`, - `targetSdkVersion ${googleCompileSdk}`, - `minSdk = ${googleMinSdk}`, - `compileSdk = ${googleCompileSdk}`, - `targetSdk = ${googleCompileSdk}`, - ], `${docsSetupFile} Android SDK values must derive from versioning.ts`); + expectNotIncludes( + docsSetupFile, + [ + `Google Play Billing Library v${googleBillingVersions[0]}`, + `Google Play Billing ${googleBillingVersions[0]}+`, + `minSdkVersion ${googleMinSdk}+`, + `compileSdkVersion ${googleCompileSdk}+`, + `minSdkVersion ${googleMinSdk}`, + `compileSdkVersion ${googleCompileSdk}`, + `targetSdkVersion ${googleCompileSdk}`, + `minSdk = ${googleMinSdk}`, + `compileSdk = ${googleCompileSdk}`, + `targetSdk = ${googleCompileSdk}`, + ], + `${docsSetupFile} Android SDK values must derive from versioning.ts`, + ); } - expectIncludes('libraries/expo-iap/plugin/src/withLocalOpenIAP.ts', [ - 'resolveAndroidGradlePluginVersions(androidModulePath)', - 'relativeAndroidModulePath', - "new File(settingsDir, '${relativeAndroidModulePath}')", - 'projectDirPattern', - 'injectPluginManagement();', - 'readGradlePluginVersion(contents,', - 'setGradlePluginVersion(', - "vanniktechMavenPublish: '0.35.0'", - 'pluginVersions.vanniktechMavenPublish', - 'pluginVersions.kotlin', - ], 'Expo local OpenIAP plugin Gradle plugin versions'); - expectOptionalIncludes('libraries/expo-iap/plugin/build/withLocalOpenIAP.js', [ - 'relativeAndroidModulePath', - "new File(settingsDir, '${relativeAndroidModulePath}')", - 'projectDirPattern', - 'injectPluginManagement();', - ], 'Expo local OpenIAP plugin build output'); - expectNotIncludes('libraries/expo-iap/plugin/src/withLocalOpenIAP.ts', [ - 'version "0.29.0"', - "new File('${androidModulePath", - ], 'Expo local OpenIAP plugin Gradle plugin versions must not drift from packages/google'); - expectOptionalIncludes('libraries/expo-iap/example/android/settings.gradle', [ - 'id("com.vanniktech.maven.publish") version "0.35.0"', - 'id("org.jetbrains.kotlin.android") version "2.2.0"', - 'id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"', - "project(':openiap-google').projectDir = new File(settingsDir, '../../../../packages/google/openiap')", - ], 'Expo example local OpenIAP plugin versions'); - expectOptionalNotIncludes('libraries/expo-iap/example/android/settings.gradle', [ - 'version "0.29.0"', - 'version "2.0.21"', - ], 'Expo example local OpenIAP plugin versions must not drift'); - expectIncludes('libraries/kmp-iap/gradle/libs.versions.toml', [ - 'vanniktech-publish = "0.35.0"', - ], 'KMP Vanniktech publish plugin version'); - expectIncludes('libraries/kmp-iap/example/gradle/libs.versions.toml', [ - 'vanniktech-publish = "0.35.0"', - ], 'KMP example Vanniktech publish plugin version'); - expectIncludes('libraries/kmp-iap/library/build.gradle.kts', [ - 'publishToMavenCentral()', - ], 'KMP Vanniktech publish target'); - expectIncludes('libraries/kmp-iap/library/build.gradle.kts', [ - 'url.set("https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap")', - 'name.set("Apache License 2.0")', - 'url.set("https://www.apache.org/licenses/LICENSE-2.0")', - 'organization.set("hyodotdev")', - 'organizationUrl.set("https://github.com/hyodotdev")', - 'connection.set("scm:git:https://github.com/hyodotdev/openiap.git")', - 'developerConnection.set("scm:git:ssh://git@github.com/hyodotdev/openiap.git")', - 'tag.set("kmp-iap-$kmpIapLibraryVersion")', - 'url.set("https://github.com/hyodotdev/openiap/issues")', - ], 'KMP Maven metadata must point at the monorepo and local license'); - expectNotIncludes('libraries/kmp-iap/library/build.gradle.kts', [ - 'github.com/hyochan/kmp-iap', - 'name.set("MIT License")', - 'https://opensource.org/licenses/MIT', - 'tag.set("v$kmpIapLibraryVersion")', - ], 'KMP Maven metadata must not point at the legacy standalone repository'); - expectIncludes('libraries/kmp-iap/library/build.gradle.kts', [ - 'fun dynamicKmpPodspec(): String', - 'tasks.matching { it.name == "podspec" }.configureEach', - 'dynamicKmpPodspec()', - ], 'KMP CocoaPods generated podspec must be post-processed by Gradle'); - expectIncludes('libraries/kmp-iap/library/library.podspec', [ - 'gradle_properties_file', - 'libraryVersion=', - 'spec.version = library_version', - 'kmp-iap-#{library_version}', - ], 'KMP CocoaPods spec version must follow gradle.properties'); - expectNotIncludes('libraries/kmp-iap/library/library.podspec', [ - "spec.version = '2.2.8'", - "spec.source = { :http=> ''}", - "spec.authors = ''", - "spec.license = ''", - ], 'KMP CocoaPods spec version must not drift from gradle.properties'); - expectIncludes('libraries/kmp-iap/publish-local.sh', [ - 'set -euo pipefail', - 'read_prop()', - 'ORG_GRADLE_PROJECT_signingInMemoryKey', - 'ORG_GRADLE_PROJECT_signingInMemoryKeyFile', - ':library:publishAndReleaseToMavenCentral', - ], 'KMP local Maven Central publish script'); - expectNotIncludes('libraries/kmp-iap/publish-local.sh', [ - 'source local.properties', - '. local.properties', - ], 'KMP local Maven Central publish script must not source local.properties'); - expectIncludes('libraries/kmp-iap/scripts/publish-local.sh', [ - 'set -euo pipefail', - '-PlibraryVersion="$VERSION"', - ], 'KMP Maven Local publish script'); - expectIncludes('libraries/kmp-iap/scripts/build-all.sh', [ - 'set -euo pipefail', - ], 'KMP build-all script must fail on unset vars and pipeline failures'); - expectNotIncludes('libraries/kmp-iap/scripts/publish-local.sh', [ - 'echo "libraryVersion=$VERSION" > local.properties', - ], 'KMP Maven Local publish script must not overwrite local.properties'); - expectIncludes('libraries/kmp-iap/local.properties.template', [ - 'mavenCentralUsername=your-central-portal-username', - 'mavenCentralPassword=your-central-portal-password', - 'signingInMemoryKeyId=', - 'signingInMemoryKeyPassword=', - 'signingInMemoryKeyFile=', - ], 'KMP local.properties template'); - expectIncludes('libraries/kmp-iap/gradle.properties.template', [ - 'mavenCentralUsername=your-central-portal-username', - 'mavenCentralPassword=your-central-portal-password', - 'signingInMemoryKeyId=', - 'signingInMemoryKeyPassword=', - 'signingInMemoryKeyFile=', - ], 'KMP gradle.properties template'); + expectIncludes( + "libraries/expo-iap/plugin/src/withLocalOpenIAP.ts", + [ + "resolveAndroidGradlePluginVersions(androidModulePath)", + "relativeAndroidModulePath", + "new File(settingsDir, '${relativeAndroidModulePath}')", + "projectDirPattern", + "injectPluginManagement();", + "readGradlePluginVersion(contents,", + "setGradlePluginVersion(", + "vanniktechMavenPublish: '0.35.0'", + "pluginVersions.vanniktechMavenPublish", + "pluginVersions.kotlin", + ], + "Expo local OpenIAP plugin Gradle plugin versions", + ); + expectOptionalIncludes( + "libraries/expo-iap/plugin/build/withLocalOpenIAP.js", + [ + "relativeAndroidModulePath", + "new File(settingsDir, '${relativeAndroidModulePath}')", + "projectDirPattern", + "injectPluginManagement();", + ], + "Expo local OpenIAP plugin build output", + ); + expectNotIncludes( + "libraries/expo-iap/plugin/src/withLocalOpenIAP.ts", + ['version "0.29.0"', "new File('${androidModulePath"], + "Expo local OpenIAP plugin Gradle plugin versions must not drift from packages/google", + ); + expectOptionalIncludes( + "libraries/expo-iap/example/android/settings.gradle", + [ + 'id("com.vanniktech.maven.publish") version "0.35.0"', + 'id("org.jetbrains.kotlin.android") version "2.2.0"', + 'id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"', + "project(':openiap-google').projectDir = new File(settingsDir, '../../../../packages/google/openiap')", + ], + "Expo example local OpenIAP plugin versions", + ); + expectOptionalNotIncludes( + "libraries/expo-iap/example/android/settings.gradle", + ['version "0.29.0"', 'version "2.0.21"'], + "Expo example local OpenIAP plugin versions must not drift", + ); + expectIncludes( + "libraries/kmp-iap/gradle/libs.versions.toml", + ['vanniktech-publish = "0.35.0"'], + "KMP Vanniktech publish plugin version", + ); + expectIncludes( + "libraries/kmp-iap/example/gradle/libs.versions.toml", + ['vanniktech-publish = "0.35.0"'], + "KMP example Vanniktech publish plugin version", + ); + expectIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + ["publishToMavenCentral()"], + "KMP Vanniktech publish target", + ); + expectIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + [ + 'url.set("https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap")', + 'name.set("Apache License 2.0")', + 'url.set("https://www.apache.org/licenses/LICENSE-2.0")', + 'organization.set("hyodotdev")', + 'organizationUrl.set("https://github.com/hyodotdev")', + 'connection.set("scm:git:https://github.com/hyodotdev/openiap.git")', + 'developerConnection.set("scm:git:ssh://git@github.com/hyodotdev/openiap.git")', + 'tag.set("kmp-iap-$kmpIapLibraryVersion")', + 'url.set("https://github.com/hyodotdev/openiap/issues")', + ], + "KMP Maven metadata must point at the monorepo and local license", + ); + expectNotIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + [ + "github.com/hyochan/kmp-iap", + 'name.set("MIT License")', + "https://opensource.org/licenses/MIT", + 'tag.set("v$kmpIapLibraryVersion")', + ], + "KMP Maven metadata must not point at the legacy standalone repository", + ); + expectIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + [ + "fun dynamicKmpPodspec(): String", + 'tasks.matching { it.name == "podspec" }.configureEach', + "dynamicKmpPodspec()", + ], + "KMP CocoaPods generated podspec must be post-processed by Gradle", + ); + expectIncludes( + "libraries/kmp-iap/library/library.podspec", + [ + "gradle_properties_file", + "libraryVersion=", + "spec.version = library_version", + "kmp-iap-#{library_version}", + ], + "KMP CocoaPods spec version must follow gradle.properties", + ); + expectNotIncludes( + "libraries/kmp-iap/library/library.podspec", + [ + "spec.version = '2.2.8'", + "spec.source = { :http=> ''}", + "spec.authors = ''", + "spec.license = ''", + ], + "KMP CocoaPods spec version must not drift from gradle.properties", + ); + expectIncludes( + "libraries/kmp-iap/publish-local.sh", + [ + "set -euo pipefail", + "read_prop()", + "ORG_GRADLE_PROJECT_signingInMemoryKey", + "ORG_GRADLE_PROJECT_signingInMemoryKeyFile", + ":library:publishAndReleaseToMavenCentral", + ], + "KMP local Maven Central publish script", + ); + expectNotIncludes( + "libraries/kmp-iap/publish-local.sh", + ["source local.properties", ". local.properties"], + "KMP local Maven Central publish script must not source local.properties", + ); + expectIncludes( + "libraries/kmp-iap/scripts/publish-local.sh", + ["set -euo pipefail", '-PlibraryVersion="$VERSION"'], + "KMP Maven Local publish script", + ); + expectIncludes( + "libraries/kmp-iap/scripts/build-all.sh", + ["set -euo pipefail"], + "KMP build-all script must fail on unset vars and pipeline failures", + ); + expectNotIncludes( + "libraries/kmp-iap/scripts/publish-local.sh", + ['echo "libraryVersion=$VERSION" > local.properties'], + "KMP Maven Local publish script must not overwrite local.properties", + ); + expectIncludes( + "libraries/kmp-iap/local.properties.template", + [ + "mavenCentralUsername=your-central-portal-username", + "mavenCentralPassword=your-central-portal-password", + "signingInMemoryKeyId=", + "signingInMemoryKeyPassword=", + "signingInMemoryKeyFile=", + ], + "KMP local.properties template", + ); + expectIncludes( + "libraries/kmp-iap/gradle.properties.template", + [ + "mavenCentralUsername=your-central-portal-username", + "mavenCentralPassword=your-central-portal-password", + "signingInMemoryKeyId=", + "signingInMemoryKeyPassword=", + "signingInMemoryKeyFile=", + ], + "KMP gradle.properties template", + ); for (const staleKmpSigningFile of [ - 'libraries/kmp-iap/local.properties.template', - 'libraries/kmp-iap/gradle.properties.template', - 'libraries/kmp-iap/gradle.properties', + "libraries/kmp-iap/local.properties.template", + "libraries/kmp-iap/gradle.properties.template", + "libraries/kmp-iap/gradle.properties", ]) { - expectNotIncludes(staleKmpSigningFile, [ - 'signing.keyId', - 'signing.password', - 'signing.secretKeyRingFile', - 'signing.gnupg.keyName', - 'signing.gnupg.passphrase', - 'your-sonatype-username', - 'your-sonatype-password', - ], `${staleKmpSigningFile} must use current Vanniktech signing properties`); + expectNotIncludes( + staleKmpSigningFile, + [ + "signing.keyId", + "signing.password", + "signing.secretKeyRingFile", + "signing.gnupg.keyName", + "signing.gnupg.passphrase", + "your-sonatype-username", + "your-sonatype-password", + ], + `${staleKmpSigningFile} must use current Vanniktech signing properties`, + ); } - expectIncludes('packages/google/scripts/publish-local.sh', [ - ':openiap:publishAndReleaseToMavenCentral', - ], 'Google local publish Central Portal task'); - expectNotIncludes('libraries/kmp-iap/gradle/libs.versions.toml', [ - 'vanniktech-publish = "0.29.0"', - 'vanniktech-publish = "0.34.0"', - 'vanniktech-publish = "0.36.0"', - ], 'KMP Vanniktech publish plugin version must not use deprecated plugin'); - expectNotIncludes('libraries/kmp-iap/example/gradle/libs.versions.toml', [ - 'vanniktech-publish = "0.29.0"', - 'vanniktech-publish = "0.34.0"', - 'vanniktech-publish = "0.36.0"', - ], 'KMP example Vanniktech publish plugin version must not use deprecated plugin'); - expectNotIncludes('libraries/kmp-iap/library/build.gradle.kts', [ - 'SonatypeHost.CENTRAL_PORTAL', - '"sonatypeRepositoryId"', - '"sonatypeAutomaticRelease"', - ], 'KMP Vanniktech publish target must use Central Portal default API'); + expectIncludes( + "packages/google/scripts/publish-local.sh", + [":openiap:publishAndReleaseToMavenCentral"], + "Google local publish Central Portal task", + ); + expectNotIncludes( + "libraries/kmp-iap/gradle/libs.versions.toml", + [ + 'vanniktech-publish = "0.29.0"', + 'vanniktech-publish = "0.34.0"', + 'vanniktech-publish = "0.36.0"', + ], + "KMP Vanniktech publish plugin version must not use deprecated plugin", + ); + expectNotIncludes( + "libraries/kmp-iap/example/gradle/libs.versions.toml", + [ + 'vanniktech-publish = "0.29.0"', + 'vanniktech-publish = "0.34.0"', + 'vanniktech-publish = "0.36.0"', + ], + "KMP example Vanniktech publish plugin version must not use deprecated plugin", + ); + expectNotIncludes( + "libraries/kmp-iap/library/build.gradle.kts", + [ + "SonatypeHost.CENTRAL_PORTAL", + '"sonatypeRepositoryId"', + '"sonatypeAutomaticRelease"', + ], + "KMP Vanniktech publish target must use Central Portal default API", + ); for (const publishingFile of [ - 'libraries/kmp-iap/gradle.properties', - 'libraries/kmp-iap/publish-local.sh', - 'packages/google/scripts/publish-local.sh', + "libraries/kmp-iap/gradle.properties", + "libraries/kmp-iap/publish-local.sh", + "packages/google/scripts/publish-local.sh", ]) { - expectNotIncludes(publishingFile, [ - 'SONATYPE_HOST', - 'sonatypeHost', - 'sonatypeRepositoryId', - 'sonatypeAutomaticRelease', - 'SONATYPE_AUTOMATIC_RELEASE', - 'mavenCentralStagingProfileId', - 'sonatypeStagingProfileId', - 'ORG_GRADLE_PROJECT_mavenCentralPublishing', - 'ORG_GRADLE_PROJECT_signAllPublications', - ], `${publishingFile} must not use legacy Sonatype host properties`); + expectNotIncludes( + publishingFile, + [ + "SONATYPE_HOST", + "sonatypeHost", + "sonatypeRepositoryId", + "sonatypeAutomaticRelease", + "SONATYPE_AUTOMATIC_RELEASE", + "mavenCentralStagingProfileId", + "sonatypeStagingProfileId", + "ORG_GRADLE_PROJECT_mavenCentralPublishing", + "ORG_GRADLE_PROJECT_signAllPublications", + ], + `${publishingFile} must not use legacy Sonatype host properties`, + ); } } @@ -2836,9 +4903,13 @@ function checkFrameworkDependencyHygiene() { googleBuildGradle, /val\s+horizonBillingCompatibilityVersion\s*=\s*"([^"]+)"/g, ); - const horizonPlatformVersions = uniqueMatches( + const horizonPlatformKotlinVersions = uniqueMatches( + googleBuildGradle, + /val\s+horizonPlatformKotlinVersion\s*=\s*"([^"]+)"/g, + ); + const horizonSerializationVersions = uniqueMatches( googleBuildGradle, - /val\s+horizonPlatformVersion\s*=\s*"([^"]+)"/g, + /val\s+horizonSerializationVersion\s*=\s*"([^"]+)"/g, ); const amazonAppstoreSdkVersions = uniqueMatches( googleBuildGradle, @@ -2846,95 +4917,166 @@ function checkFrameworkDependencyHygiene() { ); if (horizonBillingVersions.length !== 1) { fail( - `packages/google must use one Horizon Billing Compatibility version, found: ${horizonBillingVersions.join(', ') || '(none)'}`, + `packages/google must use one Horizon Billing Compatibility version, found: ${horizonBillingVersions.join(", ") || "(none)"}`, + ); + } + if ( + horizonBillingVersions.length === 1 && + horizonBillingVersions[0] !== "2.0.0" + ) { + fail( + `packages/google must use latest audited Horizon Billing Compatibility 2.0.0, found: ${horizonBillingVersions[0]}`, + ); + } + if (horizonPlatformKotlinVersions.length !== 1) { + fail( + `packages/google must use one Horizon Platform Kotlin SDK version, found: ${horizonPlatformKotlinVersions.join(", ") || "(none)"}`, ); } - if (horizonPlatformVersions.length !== 1) { + if (horizonSerializationVersions.length !== 1) { fail( - `packages/google must use one Horizon Platform SDK version, found: ${horizonPlatformVersions.join(', ') || '(none)'}`, + `packages/google must use one Horizon serialization version, found: ${horizonSerializationVersions.join(", ") || "(none)"}`, ); } if (amazonAppstoreSdkVersions.length !== 1) { fail( - `packages/google must use one Amazon Appstore SDK version, found: ${amazonAppstoreSdkVersions.join(', ') || '(none)'}`, - ); - } - expectIncludes('packages/google/openiap/build.gradle.kts', [ - 'com.meta.horizon.platform.ovr:android-platform-sdk:$horizonPlatformVersion', - 'com.meta.horizon.billingclient.api:horizon-billing-compatibility:$horizonBillingCompatibilityVersion', - ], 'packages/google Horizon dependency versions'); - - const mauiProps = read('libraries/maui-iap/src/Directory.Build.props'); - const mauiBillingVersion = mauiProps.match(/([^<]+)<\/MauiPlayBillingVersion>/)?.[1]; - const mauiAmazonAppstoreSdkVersion = mauiProps.match(/([^<]+)<\/MauiAmazonAppstoreSdkVersion>/)?.[1]; - const mauiHorizonPlatformSdkVersion = mauiProps.match(/([^<]+)<\/MauiHorizonPlatformSdkVersion>/)?.[1]; - const mauiHorizonBillingCompatibilityVersion = mauiProps.match(/([^<]+)<\/MauiHorizonBillingCompatibilityVersion>/)?.[1]; - const mauiGsonVersion = mauiProps.match(/([^<]+)<\/MauiGsonVersion>/)?.[1]; - const mauiBillingClientNuGetVersion = mauiProps.match(/([^<]+)<\/MauiBillingClientNuGetVersion>/)?.[1]; - const mauiGoogleGsonNuGetVersion = mauiProps.match(/([^<]+)<\/MauiGoogleGsonNuGetVersion>/)?.[1]; - const mauiAndroidXActivityVersion = mauiProps.match(/([^<]+)<\/MauiAndroidXActivityVersion>/)?.[1]; - const mauiAndroidXFragmentVersion = mauiProps.match(/([^<]+)<\/MauiAndroidXFragmentVersion>/)?.[1]; - const mauiAndroidXLifecycleVersion = mauiProps.match(/([^<]+)<\/MauiAndroidXLifecycleVersion>/)?.[1]; - const mauiAndroidXSavedStateVersion = mauiProps.match(/([^<]+)<\/MauiAndroidXSavedStateVersion>/)?.[1]; - expectIncludes('scripts/sync-versions.sh', [ - 'sync_maui_android_versions', - 'packages/google/openiap/build.gradle.kts', - 'MauiPlayBillingVersion', - 'MauiAmazonAppstoreSdkVersion', - 'MauiHorizonPlatformSdkVersion', - 'MauiHorizonBillingCompatibilityVersion', - 'MauiBillingClientNuGetVersion', - 'MauiGoogleGsonNuGetVersion', - 'MauiAndroidXActivityVersion', - 'MauiAndroidXLifecycleVersion', - 'mauiGsonVersion', - ], 'root version sync must update MAUI Android dependency versions from packages/google'); - expectIncludes('libraries/maui-iap/src/Directory.Build.props', [ - 'Generated by scripts/sync-versions.sh', - 'MauiPlayBillingVersion', - 'MauiAmazonAppstoreSdkVersion', - 'MauiHorizonPlatformSdkVersion', - 'MauiHorizonBillingCompatibilityVersion', - 'MauiGsonVersion', - 'MauiBillingClientNuGetVersion', - 'MauiGoogleGsonNuGetVersion', - 'MauiAndroidXActivityVersion', - 'MauiAndroidXFragmentVersion', - 'MauiAndroidXLifecycleVersion', - 'MauiAndroidXSavedStateVersion', - ], 'MAUI Directory.Build.props must be generated by version sync'); + `packages/google must use one Amazon Appstore SDK version, found: ${amazonAppstoreSdkVersions.join(", ") || "(none)"}`, + ); + } + expectIncludes( + "packages/google/openiap/build.gradle.kts", + [ + "com.meta.horizon.billingclient.api:horizon-billing-compatibility:$horizonBillingCompatibilityVersion", + "com.meta.horizon.platform.sdk:$module:$horizonPlatformKotlinVersion", + "org.jetbrains.kotlinx:kotlinx-serialization-json:$horizonSerializationVersion", + ], + "packages/google Horizon dependency versions", + ); + expectNotIncludes( + "packages/google/openiap/build.gradle.kts", + ["com.meta.horizon.platform.ovr:android-platform-sdk"], + "packages/google Horizon 2.x must not ship the legacy OVR Platform SDK", + ); + + const mauiProps = read("libraries/maui-iap/src/Directory.Build.props"); + const mauiBillingVersion = mauiProps.match( + /([^<]+)<\/MauiPlayBillingVersion>/, + )?.[1]; + const mauiAmazonAppstoreSdkVersion = mauiProps.match( + /([^<]+)<\/MauiAmazonAppstoreSdkVersion>/, + )?.[1]; + const mauiHorizonBillingCompatibilityVersion = mauiProps.match( + /([^<]+)<\/MauiHorizonBillingCompatibilityVersion>/, + )?.[1]; + const mauiHorizonPlatformKotlinVersion = mauiProps.match( + /([^<]+)<\/MauiHorizonPlatformKotlinVersion>/, + )?.[1]; + const mauiHorizonSerializationNuGetVersion = mauiProps.match( + /([^<]+)<\/MauiHorizonSerializationNuGetVersion>/, + )?.[1]; + const mauiGsonVersion = mauiProps.match( + /([^<]+)<\/MauiGsonVersion>/, + )?.[1]; + const mauiBillingClientNuGetVersion = mauiProps.match( + /([^<]+)<\/MauiBillingClientNuGetVersion>/, + )?.[1]; + const mauiGoogleGsonNuGetVersion = mauiProps.match( + /([^<]+)<\/MauiGoogleGsonNuGetVersion>/, + )?.[1]; + const mauiAndroidXActivityVersion = mauiProps.match( + /([^<]+)<\/MauiAndroidXActivityVersion>/, + )?.[1]; + const mauiAndroidXFragmentVersion = mauiProps.match( + /([^<]+)<\/MauiAndroidXFragmentVersion>/, + )?.[1]; + const mauiAndroidXLifecycleVersion = mauiProps.match( + /([^<]+)<\/MauiAndroidXLifecycleVersion>/, + )?.[1]; + const mauiAndroidXSavedStateVersion = mauiProps.match( + /([^<]+)<\/MauiAndroidXSavedStateVersion>/, + )?.[1]; + expectIncludes( + "scripts/sync-versions.sh", + [ + "sync_maui_android_versions", + "packages/google/openiap/build.gradle.kts", + "MauiPlayBillingVersion", + "MauiAmazonAppstoreSdkVersion", + "MauiHorizonBillingCompatibilityVersion", + "MauiHorizonPlatformKotlinVersion", + "MauiHorizonSerializationNuGetVersion", + "MauiBillingClientNuGetVersion", + "MauiGoogleGsonNuGetVersion", + "MauiAndroidXActivityVersion", + "MauiAndroidXLifecycleVersion", + "mauiGsonVersion", + ], + "root version sync must update MAUI Android dependency versions from packages/google", + ); + expectIncludes( + "libraries/maui-iap/src/Directory.Build.props", + [ + "Generated by scripts/sync-versions.sh", + "MauiPlayBillingVersion", + "MauiAmazonAppstoreSdkVersion", + "MauiHorizonBillingCompatibilityVersion", + "MauiHorizonPlatformKotlinVersion", + "MauiHorizonSerializationNuGetVersion", + "MauiGsonVersion", + "MauiBillingClientNuGetVersion", + "MauiGoogleGsonNuGetVersion", + "MauiAndroidXActivityVersion", + "MauiAndroidXFragmentVersion", + "MauiAndroidXLifecycleVersion", + "MauiAndroidXSavedStateVersion", + ], + "MAUI Directory.Build.props must be generated by version sync", + ); if (!mauiBillingVersion) { - fail('MAUI Directory.Build.props must define MauiPlayBillingVersion'); + fail("MAUI Directory.Build.props must define MauiPlayBillingVersion"); } if (!mauiAmazonAppstoreSdkVersion) { - fail('MAUI Directory.Build.props must define MauiAmazonAppstoreSdkVersion'); - } - if (!mauiHorizonPlatformSdkVersion) { - fail('MAUI Directory.Build.props must define MauiHorizonPlatformSdkVersion'); + fail("MAUI Directory.Build.props must define MauiAmazonAppstoreSdkVersion"); } if (!mauiHorizonBillingCompatibilityVersion) { - fail('MAUI Directory.Build.props must define MauiHorizonBillingCompatibilityVersion'); + fail( + "MAUI Directory.Build.props must define MauiHorizonBillingCompatibilityVersion", + ); + } + if (!mauiHorizonPlatformKotlinVersion) { + fail( + "MAUI Directory.Build.props must define MauiHorizonPlatformKotlinVersion", + ); + } + if (!mauiHorizonSerializationNuGetVersion) { + fail( + "MAUI Directory.Build.props must define MauiHorizonSerializationNuGetVersion", + ); } if (!mauiGsonVersion) { - fail('MAUI Directory.Build.props must define MauiGsonVersion'); + fail("MAUI Directory.Build.props must define MauiGsonVersion"); } if (!mauiBillingClientNuGetVersion) { - fail('MAUI Directory.Build.props must define MauiBillingClientNuGetVersion'); + fail( + "MAUI Directory.Build.props must define MauiBillingClientNuGetVersion", + ); } if (!mauiGoogleGsonNuGetVersion) { - fail('MAUI Directory.Build.props must define MauiGoogleGsonNuGetVersion'); + fail("MAUI Directory.Build.props must define MauiGoogleGsonNuGetVersion"); } if (!mauiAndroidXActivityVersion) { - fail('MAUI Directory.Build.props must define MauiAndroidXActivityVersion'); + fail("MAUI Directory.Build.props must define MauiAndroidXActivityVersion"); } if (!mauiAndroidXFragmentVersion) { - fail('MAUI Directory.Build.props must define MauiAndroidXFragmentVersion'); + fail("MAUI Directory.Build.props must define MauiAndroidXFragmentVersion"); } if (!mauiAndroidXLifecycleVersion) { - fail('MAUI Directory.Build.props must define MauiAndroidXLifecycleVersion'); + fail("MAUI Directory.Build.props must define MauiAndroidXLifecycleVersion"); } if (!mauiAndroidXSavedStateVersion) { - fail('MAUI Directory.Build.props must define MauiAndroidXSavedStateVersion'); + fail( + "MAUI Directory.Build.props must define MauiAndroidXSavedStateVersion", + ); } if (googleBillingVersions.length === 1) { if (mauiBillingVersion !== googleBillingVersions[0]) { @@ -2952,19 +5094,36 @@ function checkFrameworkDependencyHygiene() { ); } } - if (amazonAppstoreSdkVersions.length === 1 && mauiAmazonAppstoreSdkVersion !== amazonAppstoreSdkVersions[0]) { + if ( + amazonAppstoreSdkVersions.length === 1 && + mauiAmazonAppstoreSdkVersion !== amazonAppstoreSdkVersions[0] + ) { fail( `MAUI Amazon Appstore SDK version ${mauiAmazonAppstoreSdkVersion} must match packages/google ${amazonAppstoreSdkVersions[0]}`, ); } - if (horizonPlatformVersions.length === 1 && mauiHorizonPlatformSdkVersion !== horizonPlatformVersions[0]) { + if ( + horizonBillingVersions.length === 1 && + mauiHorizonBillingCompatibilityVersion !== horizonBillingVersions[0] + ) { fail( - `MAUI Horizon Platform SDK version ${mauiHorizonPlatformSdkVersion} must match packages/google ${horizonPlatformVersions[0]}`, + `MAUI Horizon Billing Compatibility version ${mauiHorizonBillingCompatibilityVersion} must match packages/google ${horizonBillingVersions[0]}`, ); } - if (horizonBillingVersions.length === 1 && mauiHorizonBillingCompatibilityVersion !== horizonBillingVersions[0]) { + if ( + horizonPlatformKotlinVersions.length === 1 && + mauiHorizonPlatformKotlinVersion !== horizonPlatformKotlinVersions[0] + ) { fail( - `MAUI Horizon Billing Compatibility version ${mauiHorizonBillingCompatibilityVersion} must match packages/google ${horizonBillingVersions[0]}`, + `MAUI Horizon Platform Kotlin SDK version ${mauiHorizonPlatformKotlinVersion} must match packages/google ${horizonPlatformKotlinVersions[0]}`, + ); + } + if ( + mauiHorizonSerializationNuGetVersion && + mauiHorizonSerializationNuGetVersion !== "1.11.0.1" + ) { + fail( + `MAUI Horizon serialization NuGet version must use the audited 1.11.0.1 runtime, found: ${mauiHorizonSerializationNuGetVersion}`, ); } if (googleGsonVersions.length === 1) { @@ -2973,173 +5132,286 @@ function checkFrameworkDependencyHygiene() { `MAUI Android Gson Maven version ${mauiGsonVersion} must match packages/google ${googleGsonVersions[0]}`, ); } - const mauiAndroidGradleProperties = read('libraries/maui-iap/android/gradle.properties'); - const mauiAndroidGsonVersion = - mauiAndroidGradleProperties.match(/^mauiGsonVersion=(.+)$/m)?.[1]; + const mauiAndroidGradleProperties = read( + "libraries/maui-iap/android/gradle.properties", + ); + const mauiAndroidGsonVersion = mauiAndroidGradleProperties.match( + /^mauiGsonVersion=(.+)$/m, + )?.[1]; if (!mauiAndroidGsonVersion) { - fail('MAUI Android gradle.properties must define mauiGsonVersion'); + fail("MAUI Android gradle.properties must define mauiGsonVersion"); } else if (mauiAndroidGsonVersion !== googleGsonVersions[0]) { fail( `MAUI Android Gradle Gson version ${mauiAndroidGsonVersion} must match packages/google ${googleGsonVersions[0]}`, ); } } - expectIncludes('libraries/maui-iap/android/openiap/build.gradle.kts', [ - 'googleOpenIapBuildFile', - 'readGoogleAndroidInt("compileSdk")', - 'readGoogleAndroidInt("minSdk")', - 'readMauiAndroidMinSdk()', - 'readGoogleDependencyVersion("androidx.core:core-ktx")', - 'readGoogleVariable("coroutinesVersion")', - 'compileSdk = googleCompileSdk', - 'minSdk = maxOf(googleMinSdk, mauiAndroidMinSdk)', - 'openIapAndroidStore', - 'openiap-google-amazon', - 'openiap-google-horizon', - 'missingDimensionStrategy("platform", openIapAndroidStore)', - 'mauiGsonVersion', - 'implementation("androidx.core:core-ktx:$googleCoreKtxVersion")', - 'com.google.code.gson:gson:$gsonVersion', - 'implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$googleCoroutinesVersion")', - ], 'MAUI Android facade Gson dependency version'); - expectIncludes('libraries/maui-iap/android/settings.gradle.kts', [ - 'googleRootBuildFile', - 'googlePluginVersion("com.android.library")', - 'googlePluginVersion("org.jetbrains.kotlin.android")', - 'openiap-google-horizon', - 'openiap-google-amazon', - ], 'MAUI Android Gradle plugin versions must derive from packages/google'); - expectIncludes('libraries/maui-iap/android/build.gradle.kts', [ - 'id("com.android.library") apply false', - 'id("org.jetbrains.kotlin.android") apply false', - ], 'MAUI Android root Gradle plugins'); - expectNotIncludes('libraries/maui-iap/android/openiap/build.gradle.kts', [ - 'compileSdk = 35', - 'minSdk = 24', - 'androidx.core:core-ktx:1.13.1', - 'kotlinx-coroutines-android:1.9.0', - ], 'MAUI Android facade versions must derive from openiap-google and MAUI metadata'); + expectIncludes( + "libraries/maui-iap/android/openiap/build.gradle.kts", + [ + "googleOpenIapBuildFile", + 'readGoogleAndroidInt("compileSdk")', + 'readGoogleAndroidInt("minSdk")', + "readMauiAndroidMinSdk()", + 'readGoogleDependencyVersion("androidx.core:core-ktx")', + 'readGoogleVariable("coroutinesVersion")', + "compileSdk = googleCompileSdk", + "minSdk = maxOf(googleMinSdk, mauiAndroidMinSdk)", + "openIapAndroidStore", + "openiap-google-amazon", + "openiap-google-horizon", + 'missingDimensionStrategy("platform", openIapAndroidStore)', + "mauiGsonVersion", + 'implementation("androidx.core:core-ktx:$googleCoreKtxVersion")', + "com.google.code.gson:gson:$gsonVersion", + 'implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$googleCoroutinesVersion")', + ], + "MAUI Android facade Gson dependency version", + ); + expectIncludes( + "libraries/maui-iap/android/settings.gradle.kts", + [ + "googleRootBuildFile", + 'googlePluginVersion("com.android.library")', + 'googlePluginVersion("org.jetbrains.kotlin.android")', + "openiap-google-horizon", + "openiap-google-amazon", + ], + "MAUI Android Gradle plugin versions must derive from packages/google", + ); + expectIncludes( + "libraries/maui-iap/android/build.gradle.kts", + [ + 'id("com.android.library") apply false', + 'id("org.jetbrains.kotlin.android") apply false', + ], + "MAUI Android root Gradle plugins", + ); + expectNotIncludes( + "libraries/maui-iap/android/openiap/build.gradle.kts", + [ + "compileSdk = 35", + "minSdk = 24", + "androidx.core:core-ktx:1.13.1", + "kotlinx-coroutines-android:1.9.0", + ], + "MAUI Android facade versions must derive from openiap-google and MAUI metadata", + ); for (const mauiGradlePluginFile of [ - 'libraries/maui-iap/android/settings.gradle.kts', - 'libraries/maui-iap/android/build.gradle.kts', + "libraries/maui-iap/android/settings.gradle.kts", + "libraries/maui-iap/android/build.gradle.kts", ]) { - expectNotIncludes(mauiGradlePluginFile, [ - 'version "8.7.3"', - 'version "8.13.2"', - 'version "2.2.0"', - ], `${mauiGradlePluginFile} must not hardcode Google Gradle plugin versions`); - } - expectIncludes('libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj', [ - 'Xamarin.Android.Google.BillingClient', - 'Version="$(MauiBillingClientNuGetVersion)"', - 'GoogleGson', - 'Version="$(MauiGoogleGsonNuGetVersion)"', - 'Version="$(MauiKotlinStdLibVersion)"', - 'Version="$(MauiKotlinCoroutinesVersion)"', - 'OpenIapAndroidStore', - 'OpenIapGoogleAarFlavor', - 'openiap-$(OpenIapGoogleAarFlavor)-release.aar', - 'com.amazon.device:amazon-appstore-sdk', - 'Version="$(MauiAmazonAppstoreSdkVersion)"', - 'com.meta.horizon.platform.ovr:android-platform-sdk', - 'Version="$(MauiHorizonPlatformSdkVersion)"', - 'com.meta.horizon.billingclient.api:horizon-billing-compatibility', - 'Version="$(MauiHorizonBillingCompatibilityVersion)"', - ], 'MAUI Android binding dependency versions'); - expectNotIncludes('libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj', [ - 'Version="3.0.0"', - 'Version="3.1.8"', - 'Version="18.5.0"', - 'Version="18.9.0"', - 'Version="19.0.0"', - 'Version="18.2.0"', - ], 'MAUI Android binding Google Maven dependencies must use shared props'); - expectIncludes('libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj', [ - 'net10.0', - 'net10.0-android', - 'net10.0-ios', - 'net10.0-maccatalyst', - 'Xamarin.Android.Google.BillingClient', - 'Version="$(MauiBillingClientNuGetVersion)"', - 'GoogleGson', - 'Version="$(MauiGoogleGsonNuGetVersion)"', - 'Xamarin.AndroidX.Activity.Ktx', - 'Version="$(MauiAndroidXActivityVersion)"', - 'Xamarin.AndroidX.Fragment.Ktx', - 'Version="$(MauiAndroidXFragmentVersion)"', - 'Xamarin.AndroidX.Lifecycle.Runtime.Ktx', - 'Version="$(MauiAndroidXLifecycleVersion)"', - 'Xamarin.AndroidX.SavedState.SavedState.Ktx', - 'Version="$(MauiAndroidXSavedStateVersion)"', - 'Version="$(MauiKotlinStdLibVersion)"', - 'Version="$(MauiKotlinCoroutinesVersion)"', - 'openiap-release.aar', - 'OpenIapAndroidStore', - 'OpenIapGoogleAarFlavor', - 'openiap-$(OpenIapGoogleAarFlavor)-release.aar', - 'com.amazon.device:amazon-appstore-sdk', - 'Version="$(MauiAmazonAppstoreSdkVersion)"', - 'com.meta.horizon.platform.ovr:android-platform-sdk', - 'Version="$(MauiHorizonPlatformSdkVersion)"', - 'com.meta.horizon.billingclient.api:horizon-billing-compatibility', - 'Version="$(MauiHorizonBillingCompatibilityVersion)"', - ], 'MAUI Android package dependency versions'); - expectNotIncludes('libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj', [ - ''], + "generated MAUI dependency props must preserve store output isolation", + ); + expectNotIncludes( + "packages/apple/Sources/OpenIapModule+ObjC.swift", + ["transactionDate: Date().timeIntervalSince1970"], + "Apple Objective-C bridge transaction dates must use epoch milliseconds", + ); + expectNotIncludes( + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + [ + 'net9.0-* with net10.0-*', - ], 'MAUI setup docs must describe net10 and NuGet Google dependency shape'); - expectIncludes('libraries/flutter_inapp_purchase/android/settings.gradle', [ - "new File(settingsDir, '../../../packages/google/openiap')", - ], 'Flutter Android local OpenIAP module hint'); - expectNotIncludes('libraries/flutter_inapp_purchase/android/settings.gradle', [ - 'git clone https://github.com/hyodotdev/openiap-google', - '/path/to/openiap/packages/google/openiap', - 'switch dependency', - ], 'Flutter Android local OpenIAP module hint must match monorepo flow'); - expectIncludes('libraries/flutter_inapp_purchase/CONTRIBUTING.md', [ - "project(':openiap').projectDir = new File(settingsDir, '../../../packages/google/openiap')", - '`android/build.gradle` dependency changes are needed.', - ], 'Flutter local OpenIAP debugging docs'); - expectNotIncludes('libraries/flutter_inapp_purchase/CONTRIBUTING.md', [ - 'git clone https://github.com/hyodotdev/openiap-google', - 'debugImplementation project(":openiap")', - 'releaseImplementation "io.github.hyochan.openiap:openiap-google:${openiapGoogleVersion}"', - ], 'Flutter local OpenIAP debugging docs must match automatic local project selection'); + expectIncludes( + mauiWorkflow, + [ + 'dotnet-version: "10.0.x"', + "net10.0", + "net10.0-android", + "net10.0-ios", + "net10.0-maccatalyst", + ], + "MAUI workflows must validate net10 target frameworks", + ); + } + expectIncludes( + "packages/docs/src/pages/docs/setup/maui.tsx", + [ + ".NET 9 or .NET 10 SDK", + "Google Billing, Play", + "net10.0-ios;net10.0-android;net10.0-maccatalyst", + "Replace net9.0-* with net10.0-*", + ], + "MAUI setup docs must describe net10 and NuGet Google dependency shape", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/android/settings.gradle", + ["new File(settingsDir, '../../../packages/google/openiap')"], + "Flutter Android local OpenIAP module hint", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/android/settings.gradle", + [ + "git clone https://github.com/hyodotdev/openiap-google", + "/path/to/openiap/packages/google/openiap", + "switch dependency", + ], + "Flutter Android local OpenIAP module hint must match monorepo flow", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/CONTRIBUTING.md", + [ + "project(':openiap').projectDir = new File(settingsDir, '../../../packages/google/openiap')", + "`android/build.gradle` dependency changes are needed.", + ], + "Flutter local OpenIAP debugging docs", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/CONTRIBUTING.md", + [ + "git clone https://github.com/hyodotdev/openiap-google", + 'debugImplementation project(":openiap")', + 'releaseImplementation "io.github.hyochan.openiap:openiap-google:${openiapGoogleVersion}"', + ], + "Flutter local OpenIAP debugging docs must match automatic local project selection", + ); for (const [label, filePath] of [ - ['React Native Android', 'libraries/react-native-iap/android/build.gradle'], - ['MAUI Android', 'libraries/maui-iap/android/openiap/build.gradle.kts'], - ['Godot Android GDAP', 'libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap'], + ["React Native Android", "libraries/react-native-iap/android/build.gradle"], + ["MAUI Android", "libraries/maui-iap/android/openiap/build.gradle.kts"], + [ + "Godot Android GDAP", + "libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap", + ], ]) { const coroutineVersions = uniqueMatches( read(filePath), /kotlinx-coroutines-android:([0-9.]+)/g, ); for (const version of coroutineVersions) { - if (googleCoroutineVersions.length === 1 && version !== googleCoroutineVersions[0]) { + if ( + googleCoroutineVersions.length === 1 && + version !== googleCoroutineVersions[0] + ) { fail( `${label} coroutines version ${version} must match packages/google ${googleCoroutineVersions[0]}`, ); @@ -3147,160 +5419,259 @@ function checkFrameworkDependencyHygiene() { } } if (googleCoroutineVersions.length === 1) { - expectIncludes('libraries/react-native-iap/android/gradle.properties', [ - `NitroIap_coroutinesVersion=${googleCoroutineVersions[0]}`, - 'NitroIap_playServicesBaseVersion=', - 'NitroIap_junitVersion=', - ], 'React Native Android coroutines fallback version'); - } - expectIncludes('libraries/react-native-iap/android/build.gradle', [ - 'googleRootBuildFile', - "googlePluginVersion('com.android.library')", - "googlePluginVersion('org.jetbrains.kotlin.android')", - 'NitroIap_androidGradlePluginVersion', - 'resolveOpenIapGoogleBuildFile()', - "readOpenIapGoogleVariable(googleOpenIapBuildFile, 'coroutinesVersion')", - "readOpenIapGoogleDependencyVersion(googleOpenIapBuildFile, 'junit:junit')", - 'def playServicesBaseVersion = getExtOrDefault("playServicesBaseVersion")', - 'def junitVersion = readOpenIapGoogleDependencyVersion', - 'implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"', - 'implementation "com.google.android.gms:play-services-base:$playServicesBaseVersion"', - 'testImplementation "junit:junit:$junitVersion"', - 'namespace = "com.margelo.nitro.iap"', - 'buildConfig = true', - 'prefab = true', - ], 'React Native Android Gradle versions and syntax'); - expectNotIncludes('libraries/react-native-iap/android/build.gradle', [ - 'com.android.tools.build:gradle:8.12.1', - 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.0', - "implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'", - "implementation 'com.google.android.gms:play-services-base:18.5.0'", - "testImplementation 'junit:junit:4.13.2'", - 'namespace "com.margelo.nitro.iap"', - 'ndkVersion getExtOrDefault("ndkVersion")', - 'minSdkVersion getExtOrIntegerDefault("minSdkVersion")', - 'targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")', - 'buildConfig true', - 'prefab true', - ], 'React Native Android Gradle must avoid hardcoded drift and deprecated syntax'); - expectIncludes('libraries/react-native-iap/example/android/app/build.gradle', [ - 'ndkVersion = rootProject.ext.ndkVersion', - 'compileSdk = rootProject.ext.compileSdkVersion', - 'namespace = "dev.hyo.martie"', - 'signingConfig = signingConfigs.debug', - ], 'React Native example Android Gradle syntax'); - expectNotIncludes('libraries/react-native-iap/example/android/app/build.gradle', [ - 'ndkVersion rootProject.ext.ndkVersion', - 'compileSdk rootProject.ext.compileSdkVersion', - 'namespace "dev.hyo.martie"', - 'signingConfig signingConfigs.debug', - ], 'React Native example Android Gradle must avoid deprecated syntax'); + expectIncludes( + "libraries/react-native-iap/android/gradle.properties", + [ + `NitroIap_coroutinesVersion=${googleCoroutineVersions[0]}`, + "NitroIap_playServicesBaseVersion=", + "NitroIap_junitVersion=", + ], + "React Native Android coroutines fallback version", + ); + } + expectIncludes( + "libraries/react-native-iap/android/build.gradle", + [ + "googleRootBuildFile", + "googlePluginVersion('com.android.library')", + "googlePluginVersion('org.jetbrains.kotlin.android')", + "NitroIap_androidGradlePluginVersion", + "resolveOpenIapGoogleBuildFile()", + "readOpenIapGoogleVariable(googleOpenIapBuildFile, 'coroutinesVersion')", + "readOpenIapGoogleDependencyVersion(googleOpenIapBuildFile, 'junit:junit')", + 'def playServicesBaseVersion = getExtOrDefault("playServicesBaseVersion")', + "def junitVersion = readOpenIapGoogleDependencyVersion", + 'implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"', + 'implementation "com.google.android.gms:play-services-base:$playServicesBaseVersion"', + 'testImplementation "junit:junit:$junitVersion"', + 'namespace = "com.margelo.nitro.iap"', + "buildConfig = true", + "prefab = true", + ], + "React Native Android Gradle versions and syntax", + ); + expectNotIncludes( + "libraries/react-native-iap/android/build.gradle", + [ + "com.android.tools.build:gradle:8.12.1", + "org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.0", + "implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'", + "implementation 'com.google.android.gms:play-services-base:18.5.0'", + "testImplementation 'junit:junit:4.13.2'", + 'namespace "com.margelo.nitro.iap"', + 'ndkVersion getExtOrDefault("ndkVersion")', + 'minSdkVersion getExtOrIntegerDefault("minSdkVersion")', + 'targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")', + "buildConfig true", + "prefab true", + ], + "React Native Android Gradle must avoid hardcoded drift and deprecated syntax", + ); + expectIncludes( + "libraries/react-native-iap/example/android/app/build.gradle", + [ + "ndkVersion = rootProject.ext.ndkVersion", + "compileSdk = rootProject.ext.compileSdkVersion", + 'namespace = "dev.hyo.martie"', + "signingConfig = signingConfigs.debug", + ], + "React Native example Android Gradle syntax", + ); + expectNotIncludes( + "libraries/react-native-iap/example/android/app/build.gradle", + [ + "ndkVersion rootProject.ext.ndkVersion", + "compileSdk rootProject.ext.compileSdkVersion", + 'namespace "dev.hyo.martie"', + "signingConfig signingConfigs.debug", + ], + "React Native example Android Gradle must avoid deprecated syntax", + ); for (const gradleFile of [ - 'libraries/expo-iap/android/build.gradle', - 'libraries/flutter_inapp_purchase/example/android/app/build.gradle', - 'libraries/flutter_inapp_purchase/android/build.gradle', - 'libraries/godot-iap/android/build.gradle.kts', - 'libraries/maui-iap/android/openiap/build.gradle.kts', - 'libraries/react-native-iap/android/build.gradle', - 'packages/google/Example/build.gradle.kts', - 'packages/google/openiap/build.gradle.kts', + "libraries/expo-iap/android/build.gradle", + "libraries/flutter_inapp_purchase/example/android/app/build.gradle", + "libraries/flutter_inapp_purchase/android/build.gradle", + "libraries/godot-iap/android/build.gradle.kts", + "libraries/maui-iap/android/openiap/build.gradle.kts", + "libraries/react-native-iap/android/build.gradle", + "packages/google/Example/build.gradle.kts", + "packages/google/openiap/build.gradle.kts", ]) { - expectIncludes(gradleFile, [ - 'compilerOptions', - 'JvmTarget.JVM_17', - ], `${gradleFile} Kotlin compiler target`); - expectNotIncludes(gradleFile, [ - 'kotlinOptions', - 'jvmTarget = "17"', - "jvmTarget = '17'", - ], `${gradleFile} Kotlin compiler target`); + expectIncludes( + gradleFile, + ["compilerOptions", "JvmTarget.JVM_17"], + `${gradleFile} Kotlin compiler target`, + ); + expectNotIncludes( + gradleFile, + ["kotlinOptions", 'jvmTarget = "17"', "jvmTarget = '17'"], + `${gradleFile} Kotlin compiler target`, + ); } for (const kotlinVersionFile of [ - 'libraries/expo-iap/plugin/src/withLocalOpenIAP.ts', - 'libraries/flutter_inapp_purchase/android/gradle.properties', - 'libraries/flutter_inapp_purchase/example/android/gradle.properties', - 'libraries/flutter_inapp_purchase/example/android/settings.gradle', - 'libraries/godot-iap/android/gradle.properties', - 'libraries/react-native-iap/android/gradle.properties', - 'libraries/react-native-iap/README.md', + "libraries/expo-iap/plugin/src/withLocalOpenIAP.ts", + "libraries/flutter_inapp_purchase/android/gradle.properties", + "libraries/flutter_inapp_purchase/example/android/gradle.properties", + "libraries/flutter_inapp_purchase/example/android/settings.gradle", + "libraries/godot-iap/android/gradle.properties", + "libraries/react-native-iap/android/gradle.properties", + "libraries/react-native-iap/README.md", ]) { - expectIncludes(kotlinVersionFile, [ - '2.2.0', - ], `${kotlinVersionFile} Kotlin version`); - expectNotIncludes(kotlinVersionFile, [ - '2.0.21', - '2.1.20', - '2.1.0', - ], `${kotlinVersionFile} Kotlin version`); + expectIncludes( + kotlinVersionFile, + ["2.2.0"], + `${kotlinVersionFile} Kotlin version`, + ); + expectNotIncludes( + kotlinVersionFile, + ["2.0.21", "2.1.20", "2.1.0"], + `${kotlinVersionFile} Kotlin version`, + ); } for (const kotlinVersionFile of [ - 'libraries/expo-iap/plugin/build/withLocalOpenIAP.js', + "libraries/expo-iap/plugin/build/withLocalOpenIAP.js", ]) { - expectOptionalIncludes(kotlinVersionFile, [ - '2.2.0', - ], `${kotlinVersionFile} Kotlin version`); - expectOptionalNotIncludes(kotlinVersionFile, [ - '2.0.21', - '2.1.20', - '2.1.0', - ], `${kotlinVersionFile} Kotlin version`); - } - expectIncludes('libraries/flutter_inapp_purchase/android/build.gradle', [ - "readRequiredAndroidGradleProperty(projectDir, 'openIapKotlinVersion')", - ], 'Flutter Android build.gradle must read Kotlin fallback from gradle.properties'); - expectIncludes('libraries/react-native-iap/android/build.gradle', [ - "configuredVersion('kotlinVersion', 'NitroIap_kotlinVersion')", - ], 'React Native Android build.gradle must read Kotlin fallback from gradle.properties'); - expectIncludes('packages/apple/Sources/OpenIapVersion.swift', [ - 'Bundle.module.url(forResource: "openiap-versions", withExtension: "json")', - 'cocoaPodsVersionURL()', - 'bundle.url(forResource: "OpenIAP", withExtension: "bundle")', - 'version(for: "apple")', - 'version(for: "spec")', - ], 'Apple OpenIAP runtime version'); - expectIncludes('packages/apple/openiap.podspec', [ - 's.resources', - 'Sources/openiap-versions.json', - 'openiap-versions.json', - ], 'Apple podspec version resource'); - expectNotIncludes('packages/apple/Sources/OpenIapVersion.swift', [ - '1.2.23', - '1.2.2', - 'static let current', - 'static let gqlVersion', - ], 'Apple OpenIAP runtime version'); - expectIncludes('packages/apple/wrapper/project.yml', [ - 'MARKETING_VERSION: "$(OPENIAP_MARKETING_VERSION)"', - ], 'Apple wrapper marketing version'); - expectIncludes('packages/apple/scripts/build-xcframework.sh', [ - 'openiap-versions.json', - 'read_openiap_version()', - 'python3 - "$VERSIONS_FILE" "$1"', - 'APPLE_VERSION="$(read_openiap_version apple)"', - 'OPENIAP_MARKETING_VERSION="${APPLE_VERSION}"', - ], 'Apple xcframework marketing version'); - expectIncludes('packages/apple/scripts/build-xcframework.sh', [ - 'EXPECTED_INSTALL_NAME="@rpath/OpenIAP.framework/OpenIAP"', - 'LD_DYLIB_INSTALL_NAME="${EXPECTED_INSTALL_NAME}"', - 'validate_install_names', - ], 'Apple xcframework install name'); - expectIncludes('packages/apple/wrapper/project.yml', [ - 'LD_DYLIB_INSTALL_NAME: "@rpath/$(EXECUTABLE_PATH)"', - ], 'Apple wrapper install name'); - expectNotIncludes('packages/apple/scripts/bump-version.sh', [ - 'OpenIapVersion.swift fallback', - 'Sources/OpenIapVersion.swift', - ], 'Apple bump-version should not edit runtime version source'); + expectOptionalIncludes( + kotlinVersionFile, + ["2.2.0"], + `${kotlinVersionFile} Kotlin version`, + ); + expectOptionalNotIncludes( + kotlinVersionFile, + ["2.0.21", "2.1.20", "2.1.0"], + `${kotlinVersionFile} Kotlin version`, + ); + } + expectIncludes( + "libraries/flutter_inapp_purchase/android/build.gradle", + ["readRequiredAndroidGradleProperty(projectDir, 'openIapKotlinVersion')"], + "Flutter Android build.gradle must read Kotlin fallback from gradle.properties", + ); + expectIncludes( + "libraries/react-native-iap/android/build.gradle", + ["configuredVersion('kotlinVersion', 'NitroIap_kotlinVersion')"], + "React Native Android build.gradle must read Kotlin fallback from gradle.properties", + ); + expectIncludes( + "packages/apple/Sources/OpenIapVersion.swift", + [ + 'Bundle.module.url(forResource: "openiap-versions", withExtension: "json")', + "cocoaPodsVersionURL()", + 'bundle.url(forResource: "OpenIAP", withExtension: "bundle")', + 'version(for: "apple")', + 'version(for: "spec")', + ], + "Apple OpenIAP runtime version", + ); + expectIncludes( + "packages/apple/openiap.podspec", + ["s.resources", "Sources/openiap-versions.json", "openiap-versions.json"], + "Apple podspec version resource", + ); + expectNotIncludes( + "packages/apple/Sources/OpenIapVersion.swift", + ["1.2.23", "1.2.2", "static let current", "static let gqlVersion"], + "Apple OpenIAP runtime version", + ); + expectIncludes( + "packages/apple/wrapper/project.yml", + ['MARKETING_VERSION: "$(OPENIAP_MARKETING_VERSION)"'], + "Apple wrapper marketing version", + ); + expectIncludes( + "packages/apple/scripts/build-xcframework.sh", + [ + "openiap-versions.json", + "read_openiap_version()", + 'python3 - "$VERSIONS_FILE" "$1"', + 'APPLE_VERSION="$(read_openiap_version apple)"', + 'OPENIAP_MARKETING_VERSION="${APPLE_VERSION}"', + ], + "Apple xcframework marketing version", + ); + expectIncludes( + "packages/apple/scripts/build-xcframework.sh", + [ + 'EXPECTED_INSTALL_NAME="@rpath/OpenIAP.framework/OpenIAP"', + 'LD_DYLIB_INSTALL_NAME="${EXPECTED_INSTALL_NAME}"', + "validate_install_names", + ], + "Apple xcframework install name", + ); + expectIncludes( + "packages/apple/wrapper/project.yml", + ['LD_DYLIB_INSTALL_NAME: "@rpath/$(EXECUTABLE_PATH)"'], + "Apple wrapper install name", + ); + expectNotIncludes( + "packages/apple/scripts/bump-version.sh", + ["OpenIapVersion.swift fallback", "Sources/OpenIapVersion.swift"], + "Apple bump-version should not edit runtime version source", + ); +} + +function checkReleaseNoteGroupingGuidance() { + expectIncludes( + ".codex/skills/generate-doc/SKILL.md", + [ + "## Multi-package Release Trains", + "project decision recorded from issue #206", + "Group notable changes under the affected platform package or framework", + ], + "generate-doc package-specific release guidance", + ); + expectIncludes( + "knowledge/internal/05-docs-patterns.md", + [ + "### Package-specific grouping for shared releases", + "package-specific changelog requirement", + "from issue #206 without duplicating release history", + 'Do not replace package-specific behavior with a generic "framework parity"', + ], + "internal package-specific release guidance", + ); + expectIncludes( + "packages/docs/src/pages/docs/updates/releases.tsx", + [ + "multi-store-billing-9-1-sdk-release-2026-07-11", + "Platform packages and IAPKit", + "Framework libraries", + "react-native-iap 15.4.0", + "expo-iap 4.4.0", + "flutter_inapp_purchase 9.4.0", + "godot-iap 2.4.0", + "kmp-iap 2.4.0", + "OpenIap.Maui 1.2.1", + "https://github.com/hyodotdev/openiap/issues/206", + ], + "current package-specific release note", + ); + expectNotIncludes( + "packages/docs/src/pages/docs/updates/releases.tsx", + [ + "amazon-config-plugin-option-shape-2026-07-03", + "fireos-support-2026-05-23", + "react-native-iap 15.4.0-rc.", + "expo-iap 4.4.0-rc.", + "flutter_inapp_purchase 9.4.0-rc.", + "godot-iap 2.4.0-rc.", + "kmp-iap 2.4.0-rc.", + "OpenIap.Maui 1.2.1-rc.", + ], + "current release note must contain stable package entries only", + ); } checkLibraryCoverageRegistry(); checkExpoSsotRegistry(); +checkE2eExampleIds(); checkGeneratedTypeSync(); checkGqlRuntimeExports(); checkOperationRegistry(); -checkExpoRouterExample('libraries/expo-iap/example', 'src/utils/constants.ts'); +checkFrameworkOperationBindings(); +checkExpoRouterExample("libraries/expo-iap/example", "src/utils/constants.ts"); checkReactNativeClassic(); checkFlutter(); checkKmp(); @@ -3308,13 +5679,17 @@ checkApple(); checkGoogle(); checkMaui(); checkNativeApis(); +checkBillingChoiceFieldBindings(); checkFrameworkDependencyHygiene(); +checkReleaseNoteGroupingGuidance(); expectNoExampleStorefrontIOS(); if (failures.length > 0) { - console.error(`Non-Godot parity audit failed with ${failures.length} issue(s):`); + console.error( + `Non-Godot parity audit failed with ${failures.length} issue(s):`, + ); for (const failure of failures) console.error(`- ${failure}`); process.exit(1); } -console.log('Non-Godot example/API/test parity audit passed.'); +console.log("Non-Godot example/API/test parity audit passed."); diff --git a/scripts/sync-versions.sh b/scripts/sync-versions.sh index a3dc18081..930fa13f5 100755 --- a/scripts/sync-versions.sh +++ b/scripts/sync-versions.sh @@ -207,8 +207,8 @@ sync_maui_android_versions() { local play_billing_version local amazon_appstore_sdk_version - local horizon_platform_sdk_version local horizon_billing_compatibility_version + local horizon_platform_kotlin_version local gson_version play_billing_version=$(python3 - "$google_build" <<'PY' import re @@ -232,25 +232,25 @@ if not match: print(match.group(1)) PY ) - horizon_platform_sdk_version=$(python3 - "$google_build" <<'PY' + horizon_billing_compatibility_version=$(python3 - "$google_build" <<'PY' import re import sys text = open(sys.argv[1], encoding="utf-8").read() -match = re.search(r'val\s+horizonPlatformVersion\s*=\s*"([^"]+)"', text) +match = re.search(r'val\s+horizonBillingCompatibilityVersion\s*=\s*"([^"]+)"', text) if not match: - raise SystemExit("missing horizonPlatformVersion") + raise SystemExit("missing horizonBillingCompatibilityVersion") print(match.group(1)) PY ) - horizon_billing_compatibility_version=$(python3 - "$google_build" <<'PY' + horizon_platform_kotlin_version=$(python3 - "$google_build" <<'PY' import re import sys text = open(sys.argv[1], encoding="utf-8").read() -match = re.search(r'val\s+horizonBillingCompatibilityVersion\s*=\s*"([^"]+)"', text) +match = re.search(r'val\s+horizonPlatformKotlinVersion\s*=\s*"([^"]+)"', text) if not match: - raise SystemExit("missing horizonBillingCompatibilityVersion") + raise SystemExit("missing horizonPlatformKotlinVersion") print(match.group(1)) PY ) @@ -265,11 +265,11 @@ if not match: print(match.group(1)) PY ) - python3 - "$maui_props" "$play_billing_version" "$amazon_appstore_sdk_version" "$horizon_platform_sdk_version" "$horizon_billing_compatibility_version" "$gson_version" <<'PY' + python3 - "$maui_props" "$play_billing_version" "$amazon_appstore_sdk_version" "$horizon_billing_compatibility_version" "$horizon_platform_kotlin_version" "$gson_version" <<'PY' import re import sys -path, play_billing, amazon_appstore_sdk, horizon_platform_sdk, horizon_billing_compatibility, gson = sys.argv[1:] +path, play_billing, amazon_appstore_sdk, horizon_billing_compatibility, horizon_platform_kotlin, gson = sys.argv[1:] text = open(path, encoding="utf-8").read() preserved_property_names = [ "MauiBillingClientNuGetVersion", @@ -280,6 +280,7 @@ preserved_property_names = [ "MauiAndroidXSavedStateVersion", "MauiKotlinStdLibVersion", "MauiKotlinCoroutinesVersion", + "MauiHorizonSerializationNuGetVersion", ] preserved = {} for name in preserved_property_names: @@ -289,12 +290,14 @@ for name in preserved_property_names: preserved[name] = match.group(1) content = f""" + {play_billing} {amazon_appstore_sdk} - {horizon_platform_sdk} {horizon_billing_compatibility} + {horizon_platform_kotlin} + {preserved["MauiHorizonSerializationNuGetVersion"]} {gson} {preserved["MauiBillingClientNuGetVersion"]} {preserved["MauiGoogleGsonNuGetVersion"]}

    {"Status"}{"State"}{"Validity"}{"Verified state"} {"Store"} {"Product ID"} {"Request IP"}