diff --git a/.claude/commands/audit-code.md b/.claude/commands/audit-code.md index da770402b..eef4767d2 100644 --- a/.claude/commands/audit-code.md +++ b/.claude/commands/audit-code.md @@ -224,7 +224,9 @@ Update the documentation site for users: `openiap-versions.json` only for `clientProtocol`, `google`, and `apple`; framework versions come from each library's package metadata - Derive expected versions from the explicit release plan and stable metadata; - release workflows own package-version commits + release workflows own package-version commits, except a feature PR may set + `specs/client/package.json` when the in-tree code needs the new Client + Protocol version - Link each package to its expected GitHub Release tag, per "Docs Ship With The Change" in `knowledge/internal/05-docs-patterns.md` - Document ALL changes: new features, bug fixes, breaking changes diff --git a/.claude/commands/audit-security.md b/.claude/commands/audit-security.md index d884a94ca..edd301b63 100644 --- a/.claude/commands/audit-security.md +++ b/.claude/commands/audit-security.md @@ -48,9 +48,11 @@ osv-scanner scan source \ ``` The dependency audit fails on every unaccepted advisory severity, malformed -audit output, and expired or unused exceptions. An upstream-unpatched, -build-only exception must be justified and expiring in the owning -`osv-scanner.toml`. A successful GitHub dependency-submission run is separate +audit output, and expired or unused exceptions. A temporary exception for +an advisory the lock does not clear yet (no fixed release, a fix outside +the dependents' ranges, or a clearing dependency bump that has not been +tested yet) must be justified and expiring in the owning `osv-scanner.toml`. +A successful GitHub dependency-submission run is separate evidence that the hosted graph matches the exact Bun lock commit: ```bash diff --git a/.claude/commands/release.md b/.claude/commands/release.md index bc8d94d22..0adb6e399 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -19,10 +19,13 @@ Inspect the complete public payload before publishing it. - `main` contains stable package metadata only. Run stable package releases and production docs deployment from `main`. -- `clientProtocol` mirrors `specs/client/package.json`. Never edit it directly - in a feature PR, release command, or docs deployment. A Client Protocol - release bumps the publishing manifest; `scripts/sync-versions.sh` writes the - mirror, and the audits reject a committed mismatch. +- `clientProtocol` mirrors `specs/client/package.json`. Never edit the mirror + directly; the audits reject a committed mismatch. A feature PR may set the + manifest, run `bun install --lockfile-only --ignore-scripts` and then + `scripts/sync-release-generated.sh`, and commit `bun.lock` with the staged + files, when the in-tree code needs the new Client Protocol version; the + release then dispatches with `version=current`, which publishes that version + without bumping again. - `next` is an on-demand prerelease integration branch. Run `-rc.*` and npm `next` releases from `next` only. - `next` may be absent between prerelease trains. Create it from current `main` @@ -48,10 +51,9 @@ node --test scripts/release-branch-policy.test.mjs ``` Every release lane's version-bump commit also runs -`scripts/sync-release-generated.sh`, which regenerates and stages the files -derived from version metadata (`packages/docs/src/generated/version-metadata.json`, -`packages/docs/public/llms.txt`, `packages/docs/public/llms-full.txt`, -`knowledge/_agent-context/context.md`). Expect these paths in bump commits; +`scripts/sync-release-generated.sh`, which regenerates and stages every file +that embeds release version metadata (see its `git add` list). Expect those +paths in bump commits; they are not worktree drift. Skipping this regeneration leaves `main` stale and fails the `Audit SDK Parity` / `Test Agent Scripts` clean-worktree checks on every subsequent PR. @@ -127,8 +129,9 @@ For a multi-package release train, use this order when affected: 8. `release-maui.yml` 9. `release-openiap.yml` — select one affected package per run: - `client-protocol`: `@hyodotdev/openiap-client-protocol`. Its package - version *is* the Client Protocol version; a release moves `clientProtocol` - in `openiap-versions.json` through sync. + version *is* the Client Protocol version; a release with patch, minor, + major, rc-bump or exact moves `clientProtocol` through sync, while + `current` publishes the in-tree version. - `commerce-protocol`: `@hyodotdev/openiap-commerce-protocol`; independent package version, released when its contract, runner, or artifacts change. - `cli`: `@hyodotdev/openiap`; independent package version. @@ -199,8 +202,10 @@ Train rules (mistake guards): versions and links against the published releases and correct any that differ; if that needs an edit, commit it directly to `main` together with any release-process doc updates, - and do not open a PR for that post-release docs-only commit. Run the docs - deployment; to deploy before publication, use the explicit flag in + and do not open a PR for that post-release docs-only commit. Card edits that + change wording come with the matching `scripts/audit-release-notes.test.mjs` + needles. Run the docs deployment; to deploy before publication, use the + explicit flag in `knowledge/internal/06-git-deployment.md#deploying-documentation`. If a train will not resume, trim its card to what published. CI's release note audit (`bun run audit:release-notes`) fails a PR into `main` that diff --git a/.codex/skills/ship-release/SKILL.md b/.codex/skills/ship-release/SKILL.md index 745832498..6ec9df3bb 100644 --- a/.codex/skills/ship-release/SKILL.md +++ b/.codex/skills/ship-release/SKILL.md @@ -41,7 +41,9 @@ paths. Never mutate production Convex data. a user-visible or contract-relevant change; do not bump unaffected packages for symmetry. 5. Select stable SemVer bumps from the actual compatibility impact and run all - preflight audits required by `.claude/commands/release.md`. + preflight audits required by `.claude/commands/release.md`. The in-tree + `specs/client/package.json` version is released with `version=current` when + a feature PR already set it. ## 2. Publish packages sequentially diff --git a/.github/actions/coverage-report/action.yml b/.github/actions/coverage-report/action.yml new file mode 100644 index 000000000..832dd0156 --- /dev/null +++ b/.github/actions/coverage-report/action.yml @@ -0,0 +1,60 @@ +name: Preserve and report coverage +description: Archive required local coverage and report to Codecov without blocking verified builds on service outages. +inputs: + package-path: + description: Workspace-relative package directory + required: true + flag: + description: Canonical Codecov upload flag + required: true + use-oidc: + description: Whether the caller can request an OIDC token + required: true +runs: + using: composite + steps: + - name: Preserve verified coverage + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ inputs.flag }} + path: ${{ github.workspace }}/${{ inputs.package-path }}/coverage/lcov.info + if-no-files-found: error + retention-days: 14 + + - name: Install verified Codecov CLI + id: codecov-cli + continue-on-error: true + shell: bash + working-directory: ${{ github.workspace }} + run: | + timeout 120s scripts/install-security-tool.sh codecov "$RUNNER_TEMP/codecov-cli" + cat > "$RUNNER_TEMP/codecov" <<'EOF' + #!/usr/bin/env bash + exec timeout 120s "${BASH_SOURCE[0]}-cli" "$@" + EOF + chmod 755 "$RUNNER_TEMP/codecov" + + - name: Report coverage to Codecov + id: codecov-upload + if: steps.codecov-cli.outcome == 'success' + continue-on-error: true + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 + with: + binary: ${{ runner.temp }}/codecov + use_oidc: ${{ inputs.use-oidc }} + fail_ci_if_error: true + disable_search: true + files: coverage/lcov.info + flags: ${{ inputs.flag }} + name: ${{ inputs.flag }} + network_prefix: ${{ inputs.package-path }}/ + working-directory: ${{ inputs.package-path }} + + - name: Report Codecov service failure + if: ${{ !cancelled() && (steps.codecov-cli.outcome == 'failure' || steps.codecov-upload.outcome == 'failure') }} + shell: bash + run: | + echo "::warning::Codecov reporting failed. Required local project and patch coverage passed; the LCOV artifact is available in this run." + printf '%s\n' '### Coverage reporting' '' \ + 'Required local project and patch coverage passed. Codecov reporting failed; use the archived LCOV report in this run.' \ + >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci-expo-iap.yml b/.github/workflows/ci-expo-iap.yml index cf3550bfb..971dfb5ed 100644 --- a/.github/workflows/ci-expo-iap.yml +++ b/.github/workflows/ci-expo-iap.yml @@ -12,7 +12,11 @@ on: - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" + - "scripts/install-security-tool.sh" + - "scripts/assert-lcov-coverage.mjs" + - ".github/actions/coverage-report/**" - "scripts/test-ios-log-sanitizers.sh" + - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-expo-iap.yml" - "!**/*.md" push: @@ -26,7 +30,11 @@ on: - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" + - "scripts/install-security-tool.sh" + - "scripts/assert-lcov-coverage.mjs" + - ".github/actions/coverage-report/**" - "scripts/test-ios-log-sanitizers.sh" + - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-expo-iap.yml" - "!**/*.md" @@ -100,20 +108,10 @@ jobs: - name: Test run: bun run test:coverage - - name: Enforce line coverage - run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 - - - name: Upload coverage - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 - with: - use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - disable_search: true - files: coverage/lcov.info - flags: expo-iap - name: expo-iap - network_prefix: libraries/expo-iap/ - working-directory: libraries/expo-iap + - name: Enforce project and patch coverage + env: + COVERAGE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info --component expo-iap - name: Test plugin run: bun run test:plugin @@ -121,6 +119,62 @@ jobs: - name: Consumer install smoke test run: bun run verify:consumer-install + - name: Preserve and report coverage + uses: ./.github/actions/coverage-report + with: + package-path: libraries/expo-iap + flag: expo-iap + use-oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + + android-unit: + name: Android unit tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 20 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.13 + + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('libraries/expo-iap/bun.lock', 'libraries/expo-iap/example/bun.lock') }} + restore-keys: ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install example dependencies + working-directory: libraries/expo-iap/example + run: bun install --frozen-lockfile + + - name: Build config plugin + run: bun run build:plugin + + - name: Generate Android project + working-directory: libraries/expo-iap/example + run: npx expo prebuild --platform android --no-install --clean + + - name: Install JDK + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: "temurin" + java-version: "17" + + - name: Run Android unit tests + working-directory: libraries/expo-iap/example/android + run: | + "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" ./gradlew :expo-iap:testDebugUnitTest --stacktrace + ios-xcode-27: name: iOS example (Xcode 27) if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository @@ -155,7 +209,11 @@ jobs: - name: Generate scene-based iOS project working-directory: libraries/expo-iap/example - run: npx expo prebuild --platform ios --clean + run: | + CP_CACHE_DIR="$RUNNER_TOOL_CACHE/cocoapods/$(pod --version)" + export CP_CACHE_DIR + printf 'CP_CACHE_DIR=%s\n' "$CP_CACHE_DIR" >> "$GITHUB_ENV" + npx expo prebuild --platform ios --clean - name: Install CocoaPods dependencies working-directory: libraries/expo-iap/example/ios diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index 636f1cbb9..6aae35006 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -12,6 +12,9 @@ on: - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" + - "scripts/install-security-tool.sh" + - "scripts/assert-lcov-coverage.mjs" + - ".github/actions/coverage-report/**" - ".github/workflows/ci-flutter-inapp-purchase.yml" - ".github/workflows/release-flutter.yml" - "scripts/ci/retry-gradle.sh" @@ -27,6 +30,9 @@ on: - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" + - "scripts/install-security-tool.sh" + - "scripts/assert-lcov-coverage.mjs" + - ".github/actions/coverage-report/**" - ".github/workflows/ci-flutter-inapp-purchase.yml" - ".github/workflows/release-flutter.yml" - "scripts/ci/retry-gradle.sh" @@ -80,20 +86,10 @@ jobs: - name: Filter generated coverage run: dart run tool/filter_coverage.dart - - name: Enforce line coverage - run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 - - - name: Upload coverage - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 - with: - use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - disable_search: true - files: coverage/lcov.info - flags: flutter-inapp-purchase - name: flutter-inapp-purchase - network_prefix: libraries/flutter_inapp_purchase/ - working-directory: libraries/flutter_inapp_purchase + - name: Enforce project and patch coverage + env: + COVERAGE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info --component flutter-inapp-purchase - name: Android consumer build smoke test run: | @@ -124,6 +120,13 @@ jobs: flutter build apk --release --target-platform android-arm64 done + - name: Preserve and report coverage + uses: ./.github/actions/coverage-report + with: + package-path: libraries/flutter_inapp_purchase + flag: flutter-inapp-purchase + use-oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + apple-xcode-27: name: Apple examples (Flutter 3.44 + Xcode 27) if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index a541d1bc4..f38f67ed8 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -117,6 +117,7 @@ jobs: working-directory: packages/google run: | "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" ./gradlew \ + :openiap-core:assembleRelease \ :openiap:assemblePlayRelease \ :openiap:assembleAmazonRelease \ :openiap:assembleHorizonRelease diff --git a/.github/workflows/ci-react-native-iap.yml b/.github/workflows/ci-react-native-iap.yml index a2da6b4ce..6a6d54340 100644 --- a/.github/workflows/ci-react-native-iap.yml +++ b/.github/workflows/ci-react-native-iap.yml @@ -17,6 +17,9 @@ on: - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" + - "scripts/install-security-tool.sh" + - "scripts/assert-lcov-coverage.mjs" + - ".github/actions/coverage-report/**" - "scripts/test-ios-log-sanitizers.sh" - ".github/workflows/ci-react-native-iap.yml" - "!**/*.md" @@ -36,6 +39,9 @@ on: - "packages/google/gradle/openiap-store.gradle" - "libraries-versions.jsonc" - "codecov.yml" + - "scripts/install-security-tool.sh" + - "scripts/assert-lcov-coverage.mjs" + - ".github/actions/coverage-report/**" - "scripts/test-ios-log-sanitizers.sh" - ".github/workflows/ci-react-native-iap.yml" - "!**/*.md" @@ -120,24 +126,21 @@ jobs: - name: Test run: yarn test:ci - - name: Enforce line coverage - run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 - - - name: Upload coverage - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 - with: - use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - disable_search: true - files: coverage/lcov.info - flags: react-native-iap - name: react-native-iap - network_prefix: libraries/react-native-iap/ - working-directory: libraries/react-native-iap + - name: Enforce project and patch coverage + env: + COVERAGE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info --component react-native-iap - name: Consumer install smoke test run: yarn verify:consumer-install + - name: Preserve and report coverage + uses: ./.github/actions/coverage-report + with: + package-path: libraries/react-native-iap + flag: react-native-iap + use-oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + android-unit: name: Android unit tests runs-on: ubuntu-latest @@ -263,7 +266,10 @@ jobs: - name: Install CocoaPods dependencies working-directory: libraries/react-native-iap/example/ios - run: bundle exec pod install --repo-update + run: | + CP_CACHE_DIR="$RUNNER_TOOL_CACHE/cocoapods/$(bundle exec pod --version)" + export CP_CACHE_DIR + bundle exec pod install --repo-update - name: Build scene-based iOS example working-directory: libraries/react-native-iap/example/ios diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d012b9f71..602cdfe06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,7 @@ jobs: scripts/audit-release-notes.test.mjs scripts/release-openiap.test.mjs scripts/audit-release-sync-script.test.mjs + scripts/provider-release-preflight.test.mjs scripts/npm-publish-authorization.test.mjs scripts/verify-npm-release-provenance.test.mjs scripts/generate-sbom.test.mjs @@ -228,6 +229,7 @@ jobs: - '!**/*.md' android: - 'packages/google/**' + - 'packages/conformance/**' - 'specs/client/**' - 'scripts/**' - 'openiap-versions.json' @@ -235,6 +237,10 @@ jobs: - '!**/*.md' ios: - 'packages/apple/**' + - 'packages/conformance/apple/**' + - 'packages/conformance/src/spec/**' + - 'packages/conformance/scripts/**' + - 'Package.swift' - 'specs/client/**' - 'scripts/**' - 'openiap-versions.json' @@ -584,7 +590,7 @@ jobs: - name: Run tests working-directory: packages/google run: | - "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" ./gradlew :openiap:test + "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" ./gradlew :openiap-core:test :openiap:test - name: Build Play flavor working-directory: packages/google @@ -612,6 +618,10 @@ jobs: run: | "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" ./gradlew :openiap:assembleAmazonDebug + - name: Verify independent community provider and minified host + working-directory: packages/google + run: bash scripts/verify-community-provider.sh + - name: Verify Kotlin 2.1 consumer compatibility working-directory: packages/google run: bash scripts/verify-kotlin-2.1-consumer.sh @@ -666,6 +676,12 @@ jobs: working-directory: packages/apple run: swift test + - name: Verify public provider contract and conformance + run: | + swift build --target OpenIapConformance + swift test --package-path packages/apple/compatibility/community-provider + swift run --package-path packages/apple/compatibility/community-provider/consumer -c release FixtureConsumer + test-ios-compiler-boundaries: name: Build iOS guard boundary (Xcode ${{ matrix.xcode-version }}) needs: changes diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f249fbf74..d728fcda4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,6 +52,8 @@ jobs: filters: | swift_core: - 'packages/apple/**' + - 'packages/conformance/apple/**' + - 'Package.swift' - 'libraries/kmp-iap/native/InAppPurchaseBridge/**' - 'libraries/kmp-iap/openiap-versions.json' - 'openiap-versions.json' @@ -379,8 +381,7 @@ jobs: # Clean so the traced build recompiles everything; a cache hit would # leave the CodeQL database empty. - - name: Build Apple core for analysis - working-directory: packages/apple + - name: Build Apple core and provider conformance for analysis run: | swift package clean swift build --build-tests @@ -497,7 +498,22 @@ jobs: - name: Build React Native Swift wrapper if: matrix.component == 'react-native' working-directory: libraries/react-native-iap + env: + CURL_HOME: ${{ runner.temp }}/codeql-react-native-curl + # React Native uses this base for the same Maven artifacts and checksums. + ENTERPRISE_REPOSITORY: https://repo.maven.apache.org/maven2 run: | + # CocoaPods downloads can stall even when React Native's own cache is warm. + mkdir -p "$CURL_HOME" + cat > "$CURL_HOME/.curlrc" <<'CURL_CONFIG' + connect-timeout = 30 + max-time = 300 + speed-time = 60 + speed-limit = 1024 + retry = 2 + retry-all-errors + retry-max-time = 600 + CURL_CONFIG corepack enable corepack prepare yarn@3.6.1 --activate yarn install --immutable @@ -505,6 +521,9 @@ jobs: cd example BUNDLE_FROZEN=true bundle install cd ios + # CocoaPods clears its cache when another tool version uses it. + CP_CACHE_DIR="$RUNNER_TOOL_CACHE/cocoapods/$(bundle exec pod --version)" + export CP_CACHE_DIR bundle exec pod install --repo-update xcodebuild build \ -workspace example.xcworkspace \ @@ -524,6 +543,8 @@ jobs: bun run build:plugin cd example bun install --frozen-lockfile + CP_CACHE_DIR="$RUNNER_TOOL_CACHE/cocoapods/$(pod --version)" + export CP_CACHE_DIR npx expo prebuild --platform ios --clean cd ios pod install --repo-update diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index 39ac24bc7..ad970418c 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -20,6 +20,8 @@ on: - "specs/commerce-protocol/**" - ".github/workflows/deploy-kit.yml" - "scripts/install-security-tool.sh" + - "scripts/assert-lcov-coverage.mjs" + - ".github/actions/coverage-report/**" - "bun.lock" - "package.json" pull_request: @@ -37,6 +39,8 @@ on: - "codecov.yml" - ".github/workflows/deploy-kit.yml" - "scripts/install-security-tool.sh" + - "scripts/assert-lcov-coverage.mjs" + - ".github/actions/coverage-report/**" - "bun.lock" - "package.json" workflow_dispatch: @@ -114,23 +118,15 @@ jobs: - name: Run tests (convex + server unit tests) run: bun run test:coverage - - name: Enforce server line coverage - run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 server/ - - - name: Enforce Convex line coverage - run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 48 convex/ + - name: Enforce server project and patch coverage + env: + COVERAGE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info --component iapkit-server - - name: Upload Kit coverage - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 - with: - use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - disable_search: true - files: coverage/lcov.info - flags: iapkit - name: iapkit - network_prefix: packages/kit/ - working-directory: packages/kit + - name: Enforce Convex project and patch coverage + env: + COVERAGE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info --component iapkit-convex - name: Lint + test MCP server (served by kit's /mcp route) # kit's Fly binary imports @hyodotdev/openiap-mcp-server/web from @@ -198,6 +194,13 @@ jobs: --exit-code 1 --exit-on-eol 1 + - name: Preserve and report coverage + uses: ./.github/actions/coverage-report + with: + package-path: packages/kit + flag: iapkit + use-oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + deploy: name: Deploy Convex + openiap-kit needs: verify diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index 656309ab1..89544cc0a 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -78,6 +78,12 @@ jobs: working-directory: packages/apple run: swift test + - name: Verify public provider contract and conformance + run: | + swift build --target OpenIapConformance + swift test --package-path packages/apple/compatibility/community-provider + swift run --package-path packages/apple/compatibility/community-provider/consumer -c release FixtureConsumer + release: needs: [validate-ios] permissions: diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml index bd722d9c1..4ac24dfd8 100644 --- a/.github/workflows/release-google.yml +++ b/.github/workflows/release-google.yml @@ -199,6 +199,34 @@ jobs: google "$RELEASE_BRANCH" "$SELECTED_TAG" "$VERSION" git checkout "$SELECTED_TAG" + - name: Check provider artifacts on Maven Central + id: check_providers + env: + VERSION: ${{ steps.version.outputs.version }} + IS_PRERELEASE: ${{ steps.version.outputs.is_prerelease }} + run: | + check_pom() { + local artifact=$1 version=$2 status + status=$(curl -sS -o /dev/null -w "%{http_code}" "https://repo1.maven.org/maven2/io/github/hyochan/openiap/$artifact/$version/$artifact-$version.pom" || true) + case "$status" in + 200) echo true ;; + 404) echo false ;; + *) echo "::error::Unable to verify $artifact $version (HTTP $status)" >&2; return 1 ;; + esac + } + # Existing tags before core extraction retain their original publication path. + CORE_EXISTS=true + CONFORMANCE_EXISTS=true + if [ -f packages/google/core/build.gradle.kts ]; then + CORE_EXISTS=$(check_pom openiap-core "$VERSION") + SUITE_VERSION=$(node --input-type=module -e "import {SUITE_VERSION} from './packages/conformance/src/spec/suite-version.mjs'; console.log(SUITE_VERSION)") + if [ "$IS_PRERELEASE" != "true" ]; then + CONFORMANCE_EXISTS=$(check_pom openiap-conformance "$SUITE_VERSION") + fi + fi + echo "core_exists=$CORE_EXISTS" >> "$GITHUB_OUTPUT" + echo "conformance_exists=$CONFORMANCE_EXISTS" >> "$GITHUB_OUTPUT" + - name: Check if Horizon flavor already published id: check_horizon env: @@ -299,6 +327,8 @@ jobs: - name: Preflight Maven Central publication env: + CORE_EXISTS: ${{ steps.check_providers.outputs.core_exists }} + CONFORMANCE_EXISTS: ${{ steps.check_providers.outputs.conformance_exists }} HORIZON_EXISTS: ${{ steps.check_horizon.outputs.exists }} AMAZON_EXISTS: ${{ steps.check_amazon.outputs.exists }} PLAY_EXISTS: ${{ steps.check_play.outputs.exists }} @@ -311,14 +341,17 @@ jobs: SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} run: | if [ "$RELEASE_TAG_EXISTS" != "true" ] && \ - { [ "$HORIZON_EXISTS" = "true" ] || \ + { { [ -f packages/google/core/build.gradle.kts ] && [ "$CORE_EXISTS" = "true" ]; } || \ + [ "$HORIZON_EXISTS" = "true" ] || \ [ "$AMAZON_EXISTS" = "true" ] || \ [ "$PLAY_EXISTS" = "true" ] || \ [ "$PLUGIN_EXISTS" = "true" ]; }; then echo "::error::A Google artifact is already published without a release tag; release a new version instead" exit 1 fi - if [ "$HORIZON_EXISTS" = "false" ] || \ + if [ "$CORE_EXISTS" = "false" ] || \ + [ "$CONFORMANCE_EXISTS" = "false" ] || \ + [ "$HORIZON_EXISTS" = "false" ] || \ [ "$AMAZON_EXISTS" = "false" ] || \ [ "$PLAY_EXISTS" = "false" ] || \ [ "$PLUGIN_EXISTS" = "false" ]; then @@ -367,6 +400,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add openiap-versions.json packages/*/openiap-versions.json + git add libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props git add packages/apple/Sources/OpenIapGeneratedVersion.swift git add packages/docs/src/generated/version-metadata.json git add packages/docs/package.json packages/google/package.json packages/apple/package.json @@ -399,7 +433,17 @@ jobs: - name: Build release artifacts working-directory: packages/google - run: ./gradlew :openiap:assembleRelease --no-daemon --stacktrace + run: | + tasks=(:openiap:assembleRelease) + if [ -f core/build.gradle.kts ]; then + tasks+=(:openiap-core:assembleRelease) + fi + ./gradlew "${tasks[@]}" --no-daemon --stacktrace + + - name: Build provider conformance artifact + if: steps.check_providers.outputs.conformance_exists == 'false' + working-directory: packages/google + run: ./gradlew :openiap-conformance:assembleRelease --no-daemon --stacktrace - name: Build Gradle plugin working-directory: packages/google/gradle-plugin @@ -411,8 +455,12 @@ jobs: working-directory: packages/google run: | mkdir -p release-artifacts + if [ -f core/build.gradle.kts ] && [ ! -f core/build/outputs/aar/openiap-core-release.aar ]; then + echo "::error::The provider core AAR is missing" + exit 1 + fi shopt -s nullglob - artifacts=(openiap/build/outputs/aar/*.aar openiap/build/libs/*.jar) + artifacts=(openiap/build/outputs/aar/*.aar openiap/build/libs/*.jar core/build/outputs/aar/*.aar ../conformance/android/build/outputs/aar/*.aar) if [ ${#artifacts[@]} -eq 0 ]; then echo "::error::No Google release artifacts found" exit 1 @@ -425,6 +473,7 @@ jobs: id: release_notes env: VERSION: ${{ steps.version.outputs.version }} + IS_PRERELEASE: ${{ steps.version.outputs.is_prerelease }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | RELEASE_REF="HEAD" @@ -450,6 +499,15 @@ jobs: echo "$CHANGELOG" > /tmp/changelog.txt # Create release notes + PROVIDER_ARTIFACTS="" + if [ -f packages/google/core/build.gradle.kts ]; then + PROVIDER_ARTIFACTS="- [openiap-core](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-core/$VERSION)" + if [ "$IS_PRERELEASE" != "true" ]; then + SUITE_VERSION=$(node --input-type=module -e "import {SUITE_VERSION} from './packages/conformance/src/spec/suite-version.mjs'; console.log(SUITE_VERSION)") + PROVIDER_ARTIFACTS="$PROVIDER_ARTIFACTS + - [openiap-conformance](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-conformance/$SUITE_VERSION)" + fi + fi cat > /tmp/release-notes.md <=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "@graphql-tools/executor-legacy-ws/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/executor-legacy-ws/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], "@graphql-tools/executor-legacy-ws/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "@graphql-tools/git-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/git-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/github-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/github-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/graphql-file-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/graphql-file-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/graphql-tag-pluck/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/graphql-tag-pluck/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/import/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/import/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/json-file-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/json-file-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/load/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/load/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/merge/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/merge/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/relay-operation-optimizer/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/relay-operation-optimizer/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/schema/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/schema/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], - "@graphql-tools/url-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], + "@graphql-tools/url-loader/@graphql-tools/utils": ["@graphql-tools/utils@12.0.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg=="], "@graphql-tools/url-loader/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md index 66c920d70..2bd3d51bd 100644 --- a/knowledge/_agent-context/context.md +++ b/knowledge/_agent-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated shared context for AI assistants** -> Last updated: 2026-09-30T12:11:48.065Z +> Last updated: 2026-10-06T03:37:31.290Z > > Canonical file: `knowledge/_agent-context/context.md` @@ -341,6 +341,37 @@ mirrored from `specs/client/package.json` — alongside `google` and `apple`, th native package versions. A version like `3.4.0` there is a native package version, not a protocol version. +## Store Providers + +Apple and Android share one provider contract. `StoreProviderDescriptor` +(`specs/client/src/type.graphql`) names the store id, platform, native core +version, Client Protocol version, and capability ids. Each native binding +exposes a factory (`OpenIapProviderFactory`) with a public no-argument +initializer; the app selects one provider and every SDK dispatches its +existing purchase APIs through it. + +Discovery uses one metadata key, `dev.hyo.openiap.PROVIDER`: an Android +manifest `meta-data` entry naming the factory class, or an Info.plist string +naming the Objective-C factory class. A missing Apple key uses the App Store +factory; any other invalid selection fails with a developer error, never a +fallback. + +`IapStore` is frozen: no new cases. Every purchase and verification result +carries a required `storeId`. Official ids are `apple`, `play`, `horizon`, +and `amazon` (Play keeps the `google` enum wire value). A new external store +uses `unknown` with its own stable id; a provider serving an existing store +reports that store's canonical id and legacy value. + +Registration is optional. `specs/client/src/store-registry.json` owns ids, +aliases, tiers, maintainers, repositories, coordinates, and latest conformance +reports. `experimental` entries have at least one platform binding without a +passing report; `community` entries carry a passing report for every platform +binding; `official` entries live in this monorepo. Provider conformance suites +(`packages/conformance`: Kotlin `ProviderConformanceSuite`, Swift +`ProviderConformanceSuite`) assert the provider profile behaviors while the JS +runner covers the client behaviors; a report passes only when every required +behavior passes with a matching verdict. + ## Directory Ownership Guardrail Keep each project surface under its canonical owner: @@ -1120,9 +1151,13 @@ Version is managed in `openiap-versions.json`: 2. Run `cd specs/client && bun run generate`. 3. Run `cd packages/apple && swift test` to verify compatibility. -`"clientProtocol"` is a mirror of `specs/client/package.json`. Bump the Client -Protocol there and let `./scripts/sync-versions.sh` propagate; do not edit the -mirror by hand. `"google"` and `"apple"` are native package versions and do not +`"clientProtocol"` is a mirror of `specs/client/package.json`. A feature PR may +set the manifest, run `bun install --lockfile-only --ignore-scripts` and then +`./scripts/sync-release-generated.sh`, and commit `bun.lock` with the staged +files, when the in-tree code needs the new Client Protocol version +(`sync-versions.sh` alone skips the conformance behavior ids, which embed the +protocol version); do not edit the mirror by hand. +`"google"` and `"apple"` are CI-managed native package versions and do not constrain it. Release-state, docs, and parity audits reject drift between the mirror and the publishing manifest. @@ -1269,6 +1304,29 @@ bridges and exercises source-first mappings and round trips. When a generated payload field or bridge changes, update the real platform mapping and a focused regression fixture before extending the audit expectation. +### Provider identity and selection + +Every output that carries `IapStore` also carries a required `storeId` +(purchases, verification results, descriptors). Every SDK bridge preserves +both fields through events, reads, JSON, and completion, and completes with +the full purchase; the Apple ID-only selector resolves the owned purchase +first and otherwise points to the full-purchase selector. + +External selection pairs a community id with fixed coordinates in every SDK: +`openiapStore` + `openiapProvider` (Gradle, React Native, Flutter, KMP), +`android.store` + `android.provider` (Expo), `OpenIapStore` + +`OpenIapProvider` (MAUI), `openiap/android_store` + +`openiap/android_provider` (Godot), and the Info.plist provider key (Apple). +Official ids and their aliases are rejected with coordinates. Alias tables +and `StoreIds` constants are generated from the registry with +`bun run stores:generate`; never hand-edit a generated block. Keep a major's +provider profile for as long as any registry entry cites it, or older passing +reports fail validation. + +SDK and shared code reaches the store through `OpenIapProvider` discovery on +Android and the `OpenIapModule` facade on Apple. Never import a flavor module +class from shared code; the build links the selected flavor. + ### The bug pattern A symptom like "interface exists in `types.dart` / `types.ts` / `Types.kt` but calling it does nothing / throws" means one or more of these layers is missing: @@ -1439,10 +1497,10 @@ file into its jar at build time. Every other build system reads the same names: | godot-iap | export option `openiap/android_store`; `auto` follows the device on a debug export, else play | | `openiap doctor` | reads `openiapStore`, `openiapPlatform` and the store flags with the same table | -`bun audit:parity` compares all five alias tables — the resolver, the doctor, -the Godot helper, the runtime facade in `OpenIapStore.kt` and the MAUI package -targets — because a store that resolves differently in two layers of one build -is exactly what this mechanism exists to prevent. +`bun audit:parity` compares all six alias tables — the resolver, the doctor, +the Godot helper, the Expo plugin, the runtime facade in `OpenIapStore.kt` +and the MAUI package targets — because a store that resolves differently in +two layers of one build is exactly what this mechanism exists to prevent. **Regression suite.** Every rule above is asserted by `packages/google/scripts/verify-store-resolver.sh`, which CI runs in the Test @@ -1621,10 +1679,10 @@ maps OpenIAP product queries, purchases, restore calls, and fulfillment to ### Updating Client Protocol Types and Native Compatibility -1. Update the canonical schema. A schema change that alters the contract is a - Client Protocol version bump in `specs/client/package.json`; sync then - mirrors it into `openiap-versions.json` and fails instead of silently - repairing drift. +1. Update the canonical schema. A contract change may bump the Client Protocol + in `specs/client/package.json`; refresh `bun.lock` and run + `./scripts/sync-release-generated.sh` to mirror it (the audits reject + drift), then release with `version=current`. 2. Run `cd specs/client && bun run generate` from the monorepo root. 3. Compile ALL THREE flavors to verify: ```bash @@ -2720,9 +2778,9 @@ workflow predates the tag-ref publisher cannot be repaired safely through **What happens:** 1. Updates `openiap-versions.json` -2. Regenerates release-derived files via `scripts/sync-release-generated.sh` - (docs `version-metadata.json`, `llms.txt`, `llms-full.txt`, agent - `context.md`) so they land in the same version-bump commit +2. Regenerates the release-derived files staged by + `scripts/sync-release-generated.sh` so they land in the same version-bump + commit 3. Commits the version change to the guarded release branch 4. Creates Git tag `` (bare semver) 5. Builds and tests Swift package @@ -2747,9 +2805,9 @@ workflow predates the tag-ref publisher cannot be repaired safely through **What happens:** 1. Updates `openiap-versions.json` -2. Regenerates release-derived files via `scripts/sync-release-generated.sh` - (docs `version-metadata.json`, `llms.txt`, `llms-full.txt`, agent - `context.md`) so they land in the same version-bump commit +2. Regenerates the release-derived files staged by + `scripts/sync-release-generated.sh` so they land in the same version-bump + commit 3. Commits the version change to the guarded release branch 4. Creates Git tag `google-` 5. Builds and tests Android library @@ -2907,9 +2965,9 @@ Version ownership is split: - Apple releases update `apple` version - Google releases update `google` version -- `clientProtocol` mirrors `specs/client/package.json`; a Client Protocol npm - release bumps that manifest and `scripts/sync-versions.sh` writes the new - value into `openiap-versions.json` and its copies +- `clientProtocol` mirrors `specs/client/package.json`; a feature PR or a Client + Protocol release sets that manifest and the sync writes the mirror into + `openiap-versions.json` and its copies - Native releases never move `clientProtocol`, and a Client Protocol release never moves `google` or `apple` - The docs site has no version: it deploys whatever `main` holds @@ -2929,12 +2987,16 @@ issues. Use the GitHub Actions release workflows and repository sync automation. **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 `clientProtocol`, `google`, or `apple`. Stable -version changes happen via: +**Rule:** Feature PRs must never touch `google` or `apple`. A feature PR may set +`specs/client/package.json`, run `bun install --lockfile-only --ignore-scripts` +and then `./scripts/sync-release-generated.sh`, and commit `bun.lock` with the +staged files, when the in-tree code needs the new Client Protocol version; +never hand-edit the `clientProtocol` mirror. Stable version changes happen via: -1. Release workflows (Apple Release, Google Release) -2. A Client Protocol release bumping `specs/client/package.json`, followed by - sync propagation +1. Release workflows (Apple Release, Google Release, and Client Protocol + releases with `version=patch`, `minor`, or `major`) +2. A feature PR setting `specs/client/package.json`, published by a Client + Protocol release with `version=current` 3. CI auto-bump after merge where configured @@ -3162,8 +3224,7 @@ Vercel builds. The root `openiap-versions.json` is also a version contract. `clientProtocol` must equal the version in `specs/client/package.json`, the manifest that publishes the protocol; `google` and `apple` are independent native package -versions. `scripts/sync-versions.sh` refuses an inconsistent manifest instead of -silently normalizing it. +versions. The audits, not the sync script, reject a committed mismatch. Framework package versions and Android SDK constants used by docs must flow through `packages/docs/src/generated/version-metadata.json`, which is generated diff --git a/knowledge/internal/02-architecture.md b/knowledge/internal/02-architecture.md index 86146f799..ed18fe55c 100644 --- a/knowledge/internal/02-architecture.md +++ b/knowledge/internal/02-architecture.md @@ -62,6 +62,37 @@ mirrored from `specs/client/package.json` — alongside `google` and `apple`, th native package versions. A version like `3.4.0` there is a native package version, not a protocol version. +## Store Providers + +Apple and Android share one provider contract. `StoreProviderDescriptor` +(`specs/client/src/type.graphql`) names the store id, platform, native core +version, Client Protocol version, and capability ids. Each native binding +exposes a factory (`OpenIapProviderFactory`) with a public no-argument +initializer; the app selects one provider and every SDK dispatches its +existing purchase APIs through it. + +Discovery uses one metadata key, `dev.hyo.openiap.PROVIDER`: an Android +manifest `meta-data` entry naming the factory class, or an Info.plist string +naming the Objective-C factory class. A missing Apple key uses the App Store +factory; any other invalid selection fails with a developer error, never a +fallback. + +`IapStore` is frozen: no new cases. Every purchase and verification result +carries a required `storeId`. Official ids are `apple`, `play`, `horizon`, +and `amazon` (Play keeps the `google` enum wire value). A new external store +uses `unknown` with its own stable id; a provider serving an existing store +reports that store's canonical id and legacy value. + +Registration is optional. `specs/client/src/store-registry.json` owns ids, +aliases, tiers, maintainers, repositories, coordinates, and latest conformance +reports. `experimental` entries have at least one platform binding without a +passing report; `community` entries carry a passing report for every platform +binding; `official` entries live in this monorepo. Provider conformance suites +(`packages/conformance`: Kotlin `ProviderConformanceSuite`, Swift +`ProviderConformanceSuite`) assert the provider profile behaviors while the JS +runner covers the client behaviors; a report passes only when every required +behavior passes with a matching verdict. + ## Directory Ownership Guardrail Keep each project surface under its canonical owner: diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index 0f2d884a9..a9b81c508 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -38,9 +38,13 @@ Version is managed in `openiap-versions.json`: 2. Run `cd specs/client && bun run generate`. 3. Run `cd packages/apple && swift test` to verify compatibility. -`"clientProtocol"` is a mirror of `specs/client/package.json`. Bump the Client -Protocol there and let `./scripts/sync-versions.sh` propagate; do not edit the -mirror by hand. `"google"` and `"apple"` are native package versions and do not +`"clientProtocol"` is a mirror of `specs/client/package.json`. A feature PR may +set the manifest, run `bun install --lockfile-only --ignore-scripts` and then +`./scripts/sync-release-generated.sh`, and commit `bun.lock` with the staged +files, when the in-tree code needs the new Client Protocol version +(`sync-versions.sh` alone skips the conformance behavior ids, which embed the +protocol version); do not edit the mirror by hand. +`"google"` and `"apple"` are CI-managed native package versions and do not constrain it. Release-state, docs, and parity audits reject drift between the mirror and the publishing manifest. @@ -187,6 +191,29 @@ bridges and exercises source-first mappings and round trips. When a generated payload field or bridge changes, update the real platform mapping and a focused regression fixture before extending the audit expectation. +### Provider identity and selection + +Every output that carries `IapStore` also carries a required `storeId` +(purchases, verification results, descriptors). Every SDK bridge preserves +both fields through events, reads, JSON, and completion, and completes with +the full purchase; the Apple ID-only selector resolves the owned purchase +first and otherwise points to the full-purchase selector. + +External selection pairs a community id with fixed coordinates in every SDK: +`openiapStore` + `openiapProvider` (Gradle, React Native, Flutter, KMP), +`android.store` + `android.provider` (Expo), `OpenIapStore` + +`OpenIapProvider` (MAUI), `openiap/android_store` + +`openiap/android_provider` (Godot), and the Info.plist provider key (Apple). +Official ids and their aliases are rejected with coordinates. Alias tables +and `StoreIds` constants are generated from the registry with +`bun run stores:generate`; never hand-edit a generated block. Keep a major's +provider profile for as long as any registry entry cites it, or older passing +reports fail validation. + +SDK and shared code reaches the store through `OpenIapProvider` discovery on +Android and the `OpenIapModule` facade on Apple. Never import a flavor module +class from shared code; the build links the selected flavor. + ### The bug pattern A symptom like "interface exists in `types.dart` / `types.ts` / `Types.kt` but calling it does nothing / throws" means one or more of these layers is missing: @@ -357,10 +384,10 @@ file into its jar at build time. Every other build system reads the same names: | godot-iap | export option `openiap/android_store`; `auto` follows the device on a debug export, else play | | `openiap doctor` | reads `openiapStore`, `openiapPlatform` and the store flags with the same table | -`bun audit:parity` compares all five alias tables — the resolver, the doctor, -the Godot helper, the runtime facade in `OpenIapStore.kt` and the MAUI package -targets — because a store that resolves differently in two layers of one build -is exactly what this mechanism exists to prevent. +`bun audit:parity` compares all six alias tables — the resolver, the doctor, +the Godot helper, the Expo plugin, the runtime facade in `OpenIapStore.kt` +and the MAUI package targets — because a store that resolves differently in +two layers of one build is exactly what this mechanism exists to prevent. **Regression suite.** Every rule above is asserted by `packages/google/scripts/verify-store-resolver.sh`, which CI runs in the Test @@ -539,10 +566,10 @@ maps OpenIAP product queries, purchases, restore calls, and fulfillment to ### Updating Client Protocol Types and Native Compatibility -1. Update the canonical schema. A schema change that alters the contract is a - Client Protocol version bump in `specs/client/package.json`; sync then - mirrors it into `openiap-versions.json` and fails instead of silently - repairing drift. +1. Update the canonical schema. A contract change may bump the Client Protocol + in `specs/client/package.json`; refresh `bun.lock` and run + `./scripts/sync-release-generated.sh` to mirror it (the audits reject + drift), then release with `version=current`. 2. Run `cd specs/client && bun run generate` from the monorepo root. 3. Compile ALL THREE flavors to verify: ```bash diff --git a/knowledge/internal/06-git-deployment.md b/knowledge/internal/06-git-deployment.md index f5b38ff8a..18210dce0 100644 --- a/knowledge/internal/06-git-deployment.md +++ b/knowledge/internal/06-git-deployment.md @@ -339,9 +339,9 @@ workflow predates the tag-ref publisher cannot be repaired safely through **What happens:** 1. Updates `openiap-versions.json` -2. Regenerates release-derived files via `scripts/sync-release-generated.sh` - (docs `version-metadata.json`, `llms.txt`, `llms-full.txt`, agent - `context.md`) so they land in the same version-bump commit +2. Regenerates the release-derived files staged by + `scripts/sync-release-generated.sh` so they land in the same version-bump + commit 3. Commits the version change to the guarded release branch 4. Creates Git tag `` (bare semver) 5. Builds and tests Swift package @@ -366,9 +366,9 @@ workflow predates the tag-ref publisher cannot be repaired safely through **What happens:** 1. Updates `openiap-versions.json` -2. Regenerates release-derived files via `scripts/sync-release-generated.sh` - (docs `version-metadata.json`, `llms.txt`, `llms-full.txt`, agent - `context.md`) so they land in the same version-bump commit +2. Regenerates the release-derived files staged by + `scripts/sync-release-generated.sh` so they land in the same version-bump + commit 3. Commits the version change to the guarded release branch 4. Creates Git tag `google-` 5. Builds and tests Android library @@ -526,9 +526,9 @@ Version ownership is split: - Apple releases update `apple` version - Google releases update `google` version -- `clientProtocol` mirrors `specs/client/package.json`; a Client Protocol npm - release bumps that manifest and `scripts/sync-versions.sh` writes the new - value into `openiap-versions.json` and its copies +- `clientProtocol` mirrors `specs/client/package.json`; a feature PR or a Client + Protocol release sets that manifest and the sync writes the mirror into + `openiap-versions.json` and its copies - Native releases never move `clientProtocol`, and a Client Protocol release never moves `google` or `apple` - The docs site has no version: it deploys whatever `main` holds @@ -548,10 +548,14 @@ issues. Use the GitHub Actions release workflows and repository sync automation. **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 `clientProtocol`, `google`, or `apple`. Stable -version changes happen via: +**Rule:** Feature PRs must never touch `google` or `apple`. A feature PR may set +`specs/client/package.json`, run `bun install --lockfile-only --ignore-scripts` +and then `./scripts/sync-release-generated.sh`, and commit `bun.lock` with the +staged files, when the in-tree code needs the new Client Protocol version; +never hand-edit the `clientProtocol` mirror. Stable version changes happen via: -1. Release workflows (Apple Release, Google Release) -2. A Client Protocol release bumping `specs/client/package.json`, followed by - sync propagation +1. Release workflows (Apple Release, Google Release, and Client Protocol + releases with `version=patch`, `minor`, or `major`) +2. A feature PR setting `specs/client/package.json`, published by a Client + Protocol release with `version=current` 3. CI auto-bump after merge where configured diff --git a/knowledge/internal/07-docs-consistency.md b/knowledge/internal/07-docs-consistency.md index 50cf7b043..b85544526 100644 --- a/knowledge/internal/07-docs-consistency.md +++ b/knowledge/internal/07-docs-consistency.md @@ -218,8 +218,7 @@ Vercel builds. The root `openiap-versions.json` is also a version contract. `clientProtocol` must equal the version in `specs/client/package.json`, the manifest that publishes the protocol; `google` and `apple` are independent native package -versions. `scripts/sync-versions.sh` refuses an inconsistent manifest instead of -silently normalizing it. +versions. The audits, not the sync script, reject a committed mismatch. Framework package versions and Android SDK constants used by docs must flow through `packages/docs/src/generated/version-metadata.json`, which is generated diff --git a/libraries/expo-iap/android/build.gradle b/libraries/expo-iap/android/build.gradle index 8f09be1c2..1ec83d376 100644 --- a/libraries/expo-iap/android/build.gradle +++ b/libraries/expo-iap/android/build.gradle @@ -55,7 +55,8 @@ apply from: project.file('openiap-android-sdk.gradle') // Store selection: explicit property > task flavor > connected debug device > play. apply from: project.file('openiap-store.gradle') -def openiapFlavor = openIapResolveStore('expo-iap').store +def openiapSelection = openIapResolveStore('expo-iap') +def openiapFlavor = openiapSelection.store // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. @@ -86,7 +87,10 @@ android { versionCode = 1 versionName = expoIapPackageVersion // When using local openiap-google with flavors, select the appropriate flavor - missingDimensionStrategy "platform", openiapFlavor + missingDimensionStrategy "platform", openiapSelection.platform + } + testOptions { + unitTests.includeAndroidResources = true } lint { abortOnError = false @@ -106,17 +110,8 @@ kotlin { dependencies { testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.16.1" testImplementation "org.json:json:20260719" - // `api`, so an app's own native code can use dev.hyo.openiap through this module. - if (findProject(":openiap-google") != null) { - api project(":openiap-google") - } else if (openiapFlavor == 'amazon') { - api "io.github.hyochan.openiap:openiap-google-amazon:${googleVersionString}" - } else if (openiapFlavor == 'horizon') { - api "io.github.hyochan.openiap:openiap-google-horizon:${googleVersionString}" - } else { - // Fallback to published artifact when local project isn't linked - api "io.github.hyochan.openiap:openiap-google:${googleVersionString}" - } + openIapAddStoreDependencies('api', googleVersionString, ':openiap-google') } 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 6d19d3de6..6d3ede1b6 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 @@ -3,7 +3,8 @@ 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.OpenIapProvider +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid import dev.hyo.openiap.SubscriptionReplacementModeAndroid @@ -301,7 +302,7 @@ object ExpoIapHelper { } fun setupListeners( - openIap: OpenIapModule, + openIap: OpenIapProtocol, module: Module, scope: CoroutineScope, connectionReady: java.util.concurrent.atomic.AtomicBoolean, @@ -458,7 +459,7 @@ object ExpoIapHelper { } fun cleanupListeners( - openIap: OpenIapModule, + openIap: OpenIapProtocol, handles: ListenerHandles?, ) { if (handles == null) return 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 3e5805686..997d0b487 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 @@ -7,7 +7,8 @@ import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions import dev.hyo.openiap.InitConnectionConfig import dev.hyo.openiap.OpenIapError -import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProvider +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.ProductRequest import dev.hyo.openiap.Purchase @@ -48,6 +49,7 @@ import dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid as OpenIapGetBillingCho import dev.hyo.openiap.InAppMessageCategoryAndroid as OpenIapInAppMessageCategory import dev.hyo.openiap.InAppMessageParamsAndroid as OpenIapInAppMessageParams import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams +import dev.hyo.openiap.utils.redeemOfferCode internal suspend fun endExpoConnectionWithCleanup( endConnection: suspend () -> Boolean, @@ -58,6 +60,18 @@ internal suspend fun endExpoConnectionWithCleanup( cleanup() } +internal suspend fun endExpoConnectionOrReset( + provider: Lazy<*>, + endConnection: suspend () -> Boolean, + cleanup: () -> Unit, +): Boolean { + if (!provider.isInitialized()) { + cleanup() + return true + } + return endExpoConnectionWithCleanup(endConnection, cleanup) +} + internal fun endConnectionErrorCode(error: Exception): String = (error as? OpenIapError)?.code ?: OpenIapError.ServiceDisconnected.CODE @@ -118,7 +132,8 @@ class ExpoIapModule : Module() { private val currentActivity get() = appContext.activityProvider?.currentActivity ?: throw Exceptions.MissingActivity() - private val openIap: OpenIapModule by lazy { OpenIapModule(context) } + private val openIapLazy: Lazy = lazy { OpenIapProvider.create(context) } + private val openIap: OpenIapProtocol get() = openIapLazy.value // Pass openIap directly to OpenIapStore to avoid reflection-based module loading private val openIapStore: OpenIapStore by lazy { OpenIapStore(openIap) } @@ -185,9 +200,10 @@ class ExpoIapModule : Module() { // Clear any buffered events from a failed init pendingEvents.clear() ExpoIapLog.failure("initConnection", IllegalStateException("Failed to initialize connection")) + val error = OpenIapError.InitConnection.forProvider(context) promise.reject( - OpenIapError.InitConnection.CODE, - OpenIapError.InitConnection.message, + error.code, + error.message, null, ) return@withLock @@ -206,7 +222,7 @@ class ExpoIapModule : Module() { promise.resolve(true) } catch (e: Exception) { ExpoIapLog.failure("initConnection", e) - promise.reject(OpenIapError.InitConnection.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.InitConnection.CODE, e.message, e) } } } @@ -217,10 +233,11 @@ class ExpoIapModule : Module() { scope.launch { connectionMutex.withLock { try { - val result = endExpoConnectionWithCleanup( + val result = endExpoConnectionOrReset( + provider = openIapLazy, endConnection = { openIap.endConnection() }, cleanup = { - ExpoIapHelper.cleanupListeners(openIap, listenerHandles) + listenerHandles?.let { ExpoIapHelper.cleanupListeners(openIap, it) } listenerHandles = null PromiseUtils.rejectAllPendingPromises() connectionReady.set(false) @@ -296,7 +313,7 @@ class ExpoIapModule : Module() { promise.resolve(payload) } catch (e: Exception) { ExpoIapLog.failure("getAvailableItems", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, null) } } } @@ -314,7 +331,7 @@ class ExpoIapModule : Module() { promise.resolve(null) } catch (e: Exception) { ExpoIapLog.failure("deepLinkToSubscriptionsAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, null) } } } @@ -323,10 +340,7 @@ class ExpoIapModule : Module() { ExpoIapLog.payload("openRedeemOfferCodeAndroid", null) scope.launch { try { - runCatching { currentActivity }.getOrNull()?.let(openIap::setActivity) - val handler = openIap.mutationHandlers.openRedeemOfferCodeAndroid - ?: throw OpenIapError.FeatureNotSupported() - val launched = handler() + val launched = redeemOfferCode(openIap, runCatching { currentActivity }.getOrNull()) ExpoIapLog.result("openRedeemOfferCodeAndroid", launched) promise.resolve(launched) } catch (e: Exception) { @@ -362,7 +376,7 @@ class ExpoIapModule : Module() { promise.resolve(code) } catch (e: Exception) { ExpoIapLog.failure("getStorefront", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } @@ -487,6 +501,30 @@ class ExpoIapModule : Module() { } } + AsyncFunction("restorePurchases") { promise: Promise -> + scope.launch { + try { + openIap.restorePurchases() + promise.resolve(true) + } catch (e: Exception) { + ExpoIapLog.failure("restorePurchases", e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, null) + } + } + } + + AsyncFunction("finishTransaction") { purchase: Map, isConsumable: Boolean, promise: Promise -> + scope.launch { + try { + openIap.finishTransaction(dev.hyo.openiap.PurchaseAndroid.fromJson(purchase), isConsumable) + promise.resolve(null) + } catch (e: Exception) { + ExpoIapLog.failure("finishTransaction", e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.DeveloperError.CODE, e.message, null) + } + } + } + AsyncFunction("acknowledgePurchaseAndroid") { token: String, promise: Promise -> ExpoIapLog.payload("acknowledgePurchaseAndroid", mapOf("token" to token)) scope.launch { @@ -497,7 +535,7 @@ class ExpoIapModule : Module() { promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("acknowledgePurchaseAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, null) } } } @@ -515,7 +553,7 @@ class ExpoIapModule : Module() { promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("consumePurchaseAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, null) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, null) } } } @@ -542,7 +580,7 @@ class ExpoIapModule : Module() { promise.resolve(resultMap) } catch (e: Exception) { ExpoIapLog.failure("verifyPurchase", e) - promise.reject(OpenIapError.VerificationFailed.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.VerificationFailed.CODE, e.message, e) } } } @@ -566,7 +604,7 @@ class ExpoIapModule : Module() { promise.resolve(resultMap) } catch (e: Exception) { ExpoIapLog.failure("verifyPurchaseWithProvider", e) - promise.reject(OpenIapError.VerificationFailed.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.VerificationFailed.CODE, e.message, e) } } } @@ -584,7 +622,7 @@ class ExpoIapModule : Module() { promise.resolve(result) } catch (e: Exception) { ExpoIapLog.failure("getActiveSubscriptions", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } @@ -601,7 +639,7 @@ class ExpoIapModule : Module() { promise.resolve(hasActive) } catch (e: Exception) { ExpoIapLog.failure("hasActiveSubscriptions", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } @@ -627,7 +665,7 @@ class ExpoIapModule : Module() { promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("isBillingProgramAvailableAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } @@ -653,7 +691,7 @@ class ExpoIapModule : Module() { promise.resolve(result.toJson()) } catch (e: Exception) { ExpoIapLog.failure("getBillingChoiceInfoAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } @@ -685,7 +723,7 @@ class ExpoIapModule : Module() { promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("createBillingProgramReportingDetailsAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } @@ -719,7 +757,7 @@ class ExpoIapModule : Module() { promise.resolve(result.toJson()) } catch (e: Exception) { ExpoIapLog.failure("showBillingProgramInformationDialogAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } @@ -754,7 +792,7 @@ class ExpoIapModule : Module() { promise.resolve(result.toJson()) } catch (e: Exception) { ExpoIapLog.failure("showInAppMessagesAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } @@ -802,18 +840,24 @@ class ExpoIapModule : Module() { promise.resolve(result) } catch (e: Exception) { ExpoIapLog.failure("launchExternalLinkAndroid", e) - promise.reject(OpenIapError.ServiceUnavailable.CODE, e.message, e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } OnDestroy { - ExpoIapHelper.cleanupListeners(openIap, listenerHandles) - listenerHandles = null - connectionReady.set(false) - pendingEvents.clear() - PromiseUtils.rejectAllPendingPromises() - job.cancel() + try { + listenerHandles?.let { ExpoIapHelper.cleanupListeners(openIap, it) } + } finally { + listenerHandles = null + connectionReady.set(false) + pendingEvents.clear() + try { + PromiseUtils.rejectAllPendingPromises() + } finally { + job.cancel() + } + } } } diff --git a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt index 28013e2c2..466caf63e 100644 --- a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt +++ b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt @@ -211,6 +211,59 @@ class ExpoIapHelperTest { assertTrue(cleanedUp) } + @Test + fun `end without a provider resets state without creating one`() = + runBlocking { + var cleanedUp = false + val provider = lazy { throw AssertionError("must not create a provider") } + + val result = + endExpoConnectionOrReset( + provider = provider, + endConnection = { throw AssertionError("must not touch a provider") }, + cleanup = { cleanedUp = true }, + ) + + assertTrue(result) + assertTrue(cleanedUp) + assertFalse(provider.isInitialized()) + } + + @Test + fun `end with a provider preserves false and still cleans up`() = + runBlocking { + var cleanedUp = false + + val result = + endExpoConnectionOrReset( + provider = lazyOf(Unit), + endConnection = { false }, + cleanup = { cleanedUp = true }, + ) + + assertFalse(result) + assertTrue(cleanedUp) + } + + @Test + fun `end failure with a provider still cleans up and propagates`() = + runBlocking { + val failure = IllegalStateException("teardown failed") + var cleanedUp = false + + val result = + runCatching { + endExpoConnectionOrReset( + provider = lazyOf(Unit), + endConnection = { throw failure }, + cleanup = { cleanedUp = true }, + ) + } + + assertEquals(failure, result.exceptionOrNull()) + assertTrue(cleanedUp) + } + @Test fun `verify purchase parser preserves Horizon options`() { val props = verifyPurchasePropsFromMap( diff --git a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderDestructionTest.kt b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderDestructionTest.kt new file mode 100644 index 000000000..1350b2ed3 --- /dev/null +++ b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderDestructionTest.kt @@ -0,0 +1,99 @@ +package expo.modules.iap + +import android.os.Bundle +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.OpenIapProvider +import expo.modules.kotlin.events.BasicEventListener +import expo.modules.kotlin.events.EventName +import kotlinx.coroutines.Job +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProviderDestructionTest { + @Test + fun `destruction completes after provider initialization fails`() { + val context = RuntimeEnvironment.getApplication() + val info = context.packageManager.getApplicationInfo(context.packageName, 0) + info.metaData = Bundle() + shadowOf(context.packageManager).installPackage( + android.content.pm.PackageInfo().apply { + packageName = context.packageName + applicationInfo = info + }, + ) + var attempts = 0 + val provider = + lazy { + attempts++ + OpenIapProvider.create(context) + } + val module = ExpoIapModule() + module.field("openIapLazy").set(module, provider) + assertThrows(OpenIapError.ProviderConfiguration::class.java) { provider.value } + val events = module.field("pendingEvents").get(module) as ConcurrentLinkedQueue<*> + val connected = module.field("connectionReady").get(module) as AtomicBoolean + connected.set(true) + val destroy = module.definition().eventListeners[EventName.MODULE_DESTROY] as BasicEventListener + destroy.call() + assertEquals(1, attempts) + assertFalse(provider.isInitialized()) + assertFalse(connected.get()) + assertTrue(events.isEmpty()) + assertTrue((module.field("job").get(module) as Job).isCancelled) + } + + @Test + fun `unused module can be destroyed without a host context`() { + val module = ExpoIapModule() + val destroy = module.definition().eventListeners[EventName.MODULE_DESTROY] as BasicEventListener + destroy.call() + assertTrue((module.field("job").get(module) as Job).isCancelled) + } + + @Test + fun `listener failure still clears state and cancels work`() { + val provider = + Proxy.newProxyInstance( + OpenIapProtocol::class.java.classLoader, + arrayOf(OpenIapProtocol::class.java), + ) { _, _, _ -> throw IllegalStateException("Provider cleanup failed") } as OpenIapProtocol + val module = ExpoIapModule() + module.field("openIapLazy").set(module, lazy { provider }) + module.field("listenerHandles").set( + module, + ExpoIapHelper.ListenerHandles( + purchaseUpdate = {}, + purchaseError = {}, + userChoiceBilling = {}, + developerProvidedBilling = {}, + subscriptionBillingIssue = {}, + connectionState = {}, + ), + ) + val connected = module.field("connectionReady").get(module) as AtomicBoolean + connected.set(true) + val destroy = module.definition().eventListeners[EventName.MODULE_DESTROY] as BasicEventListener + + assertThrows(IllegalStateException::class.java) { destroy.call() } + assertNull(module.field("listenerHandles").get(module)) + assertFalse(connected.get()) + assertTrue((module.field("job").get(module) as Job).isCancelled) + } + + private fun ExpoIapModule.field(name: String) = javaClass.getDeclaredField(name).apply { isAccessible = true } +} diff --git a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderEndConnectionTest.kt b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderEndConnectionTest.kt new file mode 100644 index 000000000..6eecf2574 --- /dev/null +++ b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderEndConnectionTest.kt @@ -0,0 +1,164 @@ +package expo.modules.iap + +import android.os.Looper +import dev.hyo.openiap.MutationEndConnectionHandler +import dev.hyo.openiap.OpenIapProtocol +import expo.modules.kotlin.Promise +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProviderEndConnectionTest { + @Test + fun `endConnection without a provider resets state without creating one`() { + var attempts = 0 + val provider = + lazy { + attempts++ + throw AssertionError("must not create a provider") + } + val module = ExpoIapModule() + module.field("openIapLazy").set(module, provider) + val connected = module.field("connectionReady").get(module) as AtomicBoolean + connected.set(true) + @Suppress("UNCHECKED_CAST") + val events = module.field("pendingEvents").get(module) as ConcurrentLinkedQueue>> + events.add("purchase-updated" to mapOf("test" to true)) + var resolved: Any? = null + var resolves = 0 + var rejects = 0 + val promise = + object : Promise { + override fun resolve(value: Any?) { + resolves++ + resolved = value + } + + override fun reject( + code: String?, + message: String?, + cause: Throwable?, + ) { + rejects++ + } + } + val function = module.definition().asyncFunctions.getValue("endConnection") + + @Suppress("UNCHECKED_CAST") + val body = + function.javaClass + .getDeclaredField("body") + .apply { isAccessible = true } + .get(function) as ( + Array, + Promise, + ) -> Unit + body(emptyArray(), promise) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(0, attempts) + assertEquals(1, resolves) + assertEquals(0, rejects) + assertEquals(true, resolved) + assertFalse(connected.get()) + assertTrue(events.isEmpty()) + } + + @Test + fun `endConnection with a provider removes listeners and resets state`() { + val calls = mutableListOf() + val end: MutationEndConnectionHandler = { + calls.add("endConnection") + true + } + val provider = + Proxy.newProxyInstance( + OpenIapProtocol::class.java.classLoader, + arrayOf(OpenIapProtocol::class.java), + ) { _, method, _ -> + calls.add(method.name) + when (method.name) { + "getEndConnection" -> end + else -> null + } + } as OpenIapProtocol + val module = ExpoIapModule() + module.field("openIapLazy").set(module, lazyOf(provider)) + module.field("listenerHandles").set( + module, + ExpoIapHelper.ListenerHandles( + purchaseUpdate = {}, + purchaseError = {}, + userChoiceBilling = {}, + developerProvidedBilling = {}, + subscriptionBillingIssue = {}, + connectionState = {}, + ), + ) + val connected = module.field("connectionReady").get(module) as AtomicBoolean + connected.set(true) + @Suppress("UNCHECKED_CAST") + val events = module.field("pendingEvents").get(module) as ConcurrentLinkedQueue>> + events.add("purchase-updated" to mapOf("test" to true)) + var resolved: Any? = null + var resolves = 0 + var rejects = 0 + val promise = + object : Promise { + override fun resolve(value: Any?) { + resolves++ + resolved = value + } + + override fun reject( + code: String?, + message: String?, + cause: Throwable?, + ) { + rejects++ + } + } + val function = module.definition().asyncFunctions.getValue("endConnection") + + @Suppress("UNCHECKED_CAST") + val body = + function.javaClass + .getDeclaredField("body") + .apply { isAccessible = true } + .get(function) as ( + Array, + Promise, + ) -> Unit + body(emptyArray(), promise) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, resolves) + assertEquals(0, rejects) + assertEquals(true, resolved) + assertEquals(1, calls.count { it == "endConnection" }) + for (name in listOf( + "removePurchaseUpdateListener", + "removePurchaseErrorListener", + "removeUserChoiceBillingListener", + "removeDeveloperProvidedBillingListener", + "removeSubscriptionBillingIssueListener", + "removeConnectionStateListener", + )) { + assertEquals(1, calls.count { it == name }) + } + assertNull(module.field("listenerHandles").get(module)) + assertFalse(connected.get()) + assertTrue(events.isEmpty()) + } + + private fun ExpoIapModule.field(name: String) = javaClass.getDeclaredField(name).apply { isAccessible = true } +} diff --git a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderErrorPropagationTest.kt b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderErrorPropagationTest.kt new file mode 100644 index 000000000..824d94446 --- /dev/null +++ b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ProviderErrorPropagationTest.kt @@ -0,0 +1,135 @@ +package expo.modules.iap + +import android.os.Looper +import dev.hyo.openiap.MutationInitConnectionHandler +import dev.hyo.openiap.MutationVerifyPurchaseHandler +import dev.hyo.openiap.MutationVerifyPurchaseWithProviderHandler +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapProtocol +import expo.modules.kotlin.Promise +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProviderErrorPropagationTest { + @Test + fun `verification preserves provider errors instead of declaring the receipt invalid`() { + for (error in listOf(OpenIapError.FeatureNotSupported("Use the vendor backend"), OpenIapError.NotPrepared)) { + val verify: MutationVerifyPurchaseHandler = { throw error } + val managed: MutationVerifyPurchaseWithProviderHandler = { throw error } + val provider = + Proxy.newProxyInstance(OpenIapProtocol::class.java.classLoader, arrayOf(OpenIapProtocol::class.java)) { _, method, _ -> + when (method.name) { + "getVerifyPurchase" -> verify + "getVerifyPurchaseWithProvider" -> managed + else -> null + } + } as OpenIapProtocol + val module = ExpoIapModule() + module.javaClass + .getDeclaredField("openIapLazy") + .apply { isAccessible = true } + .set(module, lazy { provider }) + for (method in listOf("verifyPurchase", "verifyPurchaseWithProvider")) { + val params = + if (method == + "verifyPurchase" + ) { + mapOf( + "google" to + mapOf("sku" to "sku", "accessToken" to "fixture", "packageName" to "test.app", "purchaseToken" to "opaque"), + ) + } else { + mapOf("provider" to "iapkit") + } + var responses = 0 + val promise = + object : Promise { + override fun resolve(value: Any?) { + fail("Unsupported verification must reject") + } + + override fun reject( + code: String?, + message: String?, + cause: Throwable?, + ) { + assertEquals(error.code, code) + assertEquals(error.message, message) + responses++ + } + } + val function = module.definition().asyncFunctions.getValue(method) + + @Suppress("UNCHECKED_CAST") + val body = + function.javaClass + .getDeclaredField("body") + .apply { isAccessible = true } + .get(function) as ( + Array, + Promise, + ) -> Unit + body(arrayOf(params), promise) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, responses) + } + } + } + + @Test + fun `initConnection preserves provider errors instead of reporting init failure`() { + val error = OpenIapError.ProviderConfiguration("No Android store provider registered.") + val init: MutationInitConnectionHandler = { throw error } + val provider = + Proxy.newProxyInstance(OpenIapProtocol::class.java.classLoader, arrayOf(OpenIapProtocol::class.java)) { _, method, _ -> + when (method.name) { + "getInitConnection" -> init + else -> null + } + } as OpenIapProtocol + val module = ExpoIapModule() + module.javaClass + .getDeclaredField("openIapLazy") + .apply { isAccessible = true } + .set(module, lazy { provider }) + var responses = 0 + val promise = + object : Promise { + override fun resolve(value: Any?) { + fail("A throwing provider must reject") + } + + override fun reject( + code: String?, + message: String?, + cause: Throwable?, + ) { + assertEquals(error.code, code) + assertEquals(error.message, message) + responses++ + } + } + val function = module.definition().asyncFunctions.getValue("initConnection") + + @Suppress("UNCHECKED_CAST") + val body = + function.javaClass + .getDeclaredField("body") + .apply { isAccessible = true } + .get(function) as ( + Array, + Promise, + ) -> Unit + body(arrayOf(null), promise) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, responses) + } +} diff --git a/libraries/expo-iap/bun.lock b/libraries/expo-iap/bun.lock index d6562eda2..2683644f9 100644 --- a/libraries/expo-iap/bun.lock +++ b/libraries/expo-iap/bun.lock @@ -756,7 +756,7 @@ "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], - "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + "compression": ["compression@1.8.2", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -1638,7 +1638,7 @@ "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], diff --git a/libraries/expo-iap/example/__tests__/offer-code.test.tsx b/libraries/expo-iap/example/__tests__/offer-code.test.tsx index 31948df07..04a650687 100644 --- a/libraries/expo-iap/example/__tests__/offer-code.test.tsx +++ b/libraries/expo-iap/example/__tests__/offer-code.test.tsx @@ -4,7 +4,6 @@ import {Platform, Alert} from 'react-native'; import OfferCode from '../app/offer-code'; import * as ExpoIap from 'expo-iap'; -// Mock Alert jest.spyOn(Alert, 'alert').mockImplementation(() => {}); jest.mock('expo-iap', () => ({ @@ -13,6 +12,7 @@ jest.mock('expo-iap', () => ({ id: 'redeemed-transaction', productId: 'premium', store: 'apple', + storeId: 'apple', }), ), useIAP: jest.fn(() => ({ @@ -46,7 +46,6 @@ describe('OfferCode Component', () => { }); const {getByText} = await render(); - // Check for iOS-specific text from the actual component expect( getByText(/Tap the button below to open the redemption sheet/), ).toBeDefined(); @@ -55,18 +54,20 @@ describe('OfferCode Component', () => { ).toBeDefined(); }); - it('should show Android instructions on Android', async () => { + it('should show selected-store instructions on Android', async () => { Object.defineProperty(Platform, 'OS', { get: jest.fn(() => 'android'), configurable: true, }); - const {getByText} = await render(); - // Check for Android-specific text from the actual component - expect(getByText(/Tap the button to open Google Play Store/)).toBeDefined(); + const {getByText, queryByText} = await render(); + expect( + getByText(/Open the selected store’s redemption flow/), + ).toBeDefined(); expect( - getByText(/Android requires redemption through Google Play Store/), + getByText(/Android redemption support depends on the selected store/), ).toBeDefined(); + expect(queryByText(/Google Play Store/)).toBeNull(); }); it('should show Vega unsupported guidance without calling platform redemption APIs', async () => { @@ -92,12 +93,10 @@ describe('OfferCode Component', () => { }); const {getByText} = await render(); - // The button text is "🎁 Redeem Offer Code" on iOS const redeemButton = getByText('🎁 Redeem Offer Code'); await fireEvent.press(redeemButton); - // Wait for async operation and Alert await waitFor(() => { expect(ExpoIap.openRedeemOfferCode).toHaveBeenCalled(); expect(Alert.alert).toHaveBeenCalledWith( @@ -112,21 +111,19 @@ describe('OfferCode Component', () => { get: jest.fn(() => 'android'), configurable: true, }); - // Android resolves null after launching the Play redeem page + // Android providers may launch redemption without returning a purchase. jest.mocked(ExpoIap.openRedeemOfferCode).mockResolvedValueOnce(null); const {getByText} = await render(); - // The button text is "🎁 Open Play Store" on Android - const redeemButton = getByText('🎁 Open Play Store'); + const redeemButton = getByText('🎁 Open Redemption Flow'); await fireEvent.press(redeemButton); - // Wait for async operation and Alert await waitFor(() => { - expect(ExpoIap.openRedeemOfferCode).toHaveBeenCalled(); + expect(ExpoIap.openRedeemOfferCode).toHaveBeenCalledTimes(1); expect(Alert.alert).toHaveBeenCalledWith( 'Redemption Requested', - 'Google Play opens its redeem page; stores without one open nothing. Refresh available purchases after redeeming.', + 'The selected store opens its redemption flow when supported. Refresh available purchases after redeeming.', ); }); }); @@ -159,7 +156,7 @@ describe('OfferCode Component', () => { .mockRejectedValueOnce(new Error('Unable to launch redeem page')); const {getByText} = await render(); - await fireEvent.press(getByText('🎁 Open Play Store')); + await fireEvent.press(getByText('🎁 Open Redemption Flow')); await waitFor(() => { expect(Alert.alert).toHaveBeenCalledWith( diff --git a/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx b/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx index 82e8900c3..e318857b7 100644 --- a/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx @@ -83,6 +83,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'apple', + storeId: 'apple', }, }); mockUseIAP.connected = true; @@ -206,6 +207,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }); @@ -232,6 +234,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'pending-apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }, @@ -259,6 +262,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'google', + storeId: 'play', }, }); const purchase = { @@ -266,6 +270,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'google-token-1', store: 'google', + storeId: 'play', transactionDate: Date.now(), purchaseState: 'purchased', }; @@ -296,6 +301,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'finish-failure-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }; @@ -332,6 +338,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'device-apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }); @@ -369,6 +376,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'hosted-apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }); @@ -393,6 +401,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', state: 'consumed', store: 'apple', + storeId: 'apple', }, }, }, @@ -405,6 +414,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.30bulbs', state: 'ready-to-consume', store: 'apple', + storeId: 'apple', }, }, }, @@ -418,6 +428,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'rejected-apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }); @@ -434,6 +445,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'restored-apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }, @@ -445,6 +457,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', state: 'consumed', store: 'apple', + storeId: 'apple', }, }); @@ -463,6 +476,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'restored-10-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }, @@ -471,6 +485,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.30bulbs', purchaseToken: 'restored-30-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now() + 1, purchaseState: 'purchased', }, @@ -488,6 +503,7 @@ describe('PurchaseFlow Component', () => { : 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'apple', + storeId: 'apple', }, }); }); @@ -516,6 +532,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'rerender-restored-10-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }, @@ -524,6 +541,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.30bulbs', purchaseToken: 'rerender-restored-30-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now() + 1, purchaseState: 'purchased', }, @@ -535,6 +553,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'apple', + storeId: 'apple', }, }; let resolveFirst: ((value: typeof firstResult) => void) | undefined; @@ -552,6 +571,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.30bulbs', state: 'ready-to-consume', store: 'apple', + storeId: 'apple', }, }); }); @@ -587,6 +607,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'reconnect-restored-10-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }; @@ -597,6 +618,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'apple', + storeId: 'apple', }, }; let resolveVerification: ((value: typeof result) => void) | undefined; @@ -640,6 +662,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'remount-pending-finish-10-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }; @@ -709,6 +732,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'live-purchase-10-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }, @@ -717,6 +741,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.30bulbs', purchaseToken: 'live-purchase-30-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now() + 1, purchaseState: 'purchased', }, @@ -728,6 +753,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'apple', + storeId: 'apple', }, }; let resolveFirst: ((value: typeof firstResult) => void) | undefined; @@ -745,6 +771,7 @@ describe('PurchaseFlow Component', () => { productId: 'dev.hyo.martie.30bulbs', state: 'ready-to-consume', store: 'apple', + storeId: 'apple', }, }); }); diff --git a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx index 4697c88b0..9e5792370 100644 --- a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx @@ -39,6 +39,7 @@ const mockVerifyPurchaseWithProvider = jest productId: 'dev.hyo.martie.premium', state: 'entitled', store: 'google', + storeId: 'play', }, }), ) @@ -132,6 +133,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', state: 'entitled', store: 'google', + storeId: 'play', }, }); mockOnPurchaseSuccess = undefined; @@ -521,6 +523,7 @@ describe('SubscriptionFlow Component', () => { await mockOnPurchaseSuccess?.({ id: 'transaction-android-1', store: 'google', + storeId: 'play', productId: 'dev.hyo.martie.premium', purchaseToken: 'android-token', transactionDate: Date.now(), @@ -568,6 +571,7 @@ describe('SubscriptionFlow Component', () => { await mockOnPurchaseSuccess?.({ id: 'transaction-device-sub-1', store: 'apple', + storeId: 'apple', productId: 'dev.hyo.martie.premium', purchaseToken: 'device-sub-jws', transactionDate: Date.now(), @@ -600,6 +604,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', state: 'expired', store: 'google', + storeId: 'play', }, }, }, @@ -612,6 +617,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium_year', state: 'entitled', store: 'google', + storeId: 'play', }, }, }, @@ -627,6 +633,7 @@ describe('SubscriptionFlow Component', () => { await mockOnPurchaseSuccess?.({ id: 'transaction-rejected-sub-1', store: 'google', + storeId: 'play', productId: 'dev.hyo.martie.premium', purchaseToken: 'rejected-google-token', transactionDate: Date.now(), @@ -648,6 +655,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'restored-apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), transactionReasonIOS: 'RENEWAL', }; @@ -657,6 +665,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium_year', state: 'entitled', store: 'apple', + storeId: 'apple', }, }); mockUseIAP.mockReturnValue({ @@ -692,6 +701,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'restored-monthly-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), transactionReasonIOS: 'RENEWAL', }, @@ -701,6 +711,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium_year', purchaseToken: 'restored-yearly-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now() + 1, transactionReasonIOS: 'RENEWAL', }, @@ -718,6 +729,7 @@ describe('SubscriptionFlow Component', () => { : 'dev.hyo.martie.premium', state: 'entitled', store: 'apple', + storeId: 'apple', }, }); }); @@ -763,6 +775,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'rerender-monthly-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), transactionReasonIOS: 'RENEWAL', }, @@ -772,6 +785,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium_year', purchaseToken: 'rerender-yearly-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now() + 1, transactionReasonIOS: 'RENEWAL', }, @@ -783,6 +797,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', state: 'entitled', store: 'apple', + storeId: 'apple', }, }; let resolveFirst: ((value: typeof firstResult) => void) | undefined; @@ -800,6 +815,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium_year', state: 'entitled', store: 'apple', + storeId: 'apple', }, }); }); @@ -855,6 +871,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'reconnect-monthly-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), transactionReasonIOS: 'RENEWAL', }; @@ -865,6 +882,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', state: 'entitled', store: 'apple', + storeId: 'apple', }, }; let resolveVerification: ((value: typeof result) => void) | undefined; @@ -930,6 +948,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'remount-subscription-pending-finish-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), transactionReasonIOS: 'PURCHASE', }; @@ -946,6 +965,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', state: 'entitled', store: 'apple', + storeId: 'apple', }, }); const hookValue = { @@ -1028,6 +1048,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'live-monthly-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), transactionReasonIOS: 'PURCHASE', }, @@ -1037,6 +1058,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium_year', purchaseToken: 'live-yearly-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now() + 1, transactionReasonIOS: 'PURCHASE', }, @@ -1048,6 +1070,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium', state: 'entitled', store: 'apple', + storeId: 'apple', }, }; let resolveFirst: ((value: typeof firstResult) => void) | undefined; @@ -1065,6 +1088,7 @@ describe('SubscriptionFlow Component', () => { productId: 'dev.hyo.martie.premium_year', state: 'entitled', store: 'apple', + storeId: 'apple', }, }); }); diff --git a/libraries/expo-iap/example/__tests__/validate-receipt.test.tsx b/libraries/expo-iap/example/__tests__/validate-receipt.test.tsx index e98e9126e..b362b0b7e 100644 --- a/libraries/expo-iap/example/__tests__/validate-receipt.test.tsx +++ b/libraries/expo-iap/example/__tests__/validate-receipt.test.tsx @@ -68,6 +68,7 @@ describe('Purchase Token Unified API Tests', () => { const purchase = { id: 'test-id', store: 'apple', + storeId: 'apple', productId: 'test.product', transactionDate: Date.now(), purchaseToken: 'unified-purchase-token', diff --git a/libraries/expo-iap/example/__tests__/vega-runtime.test.ts b/libraries/expo-iap/example/__tests__/vega-runtime.test.ts index ba7d4c775..db624de1d 100644 --- a/libraries/expo-iap/example/__tests__/vega-runtime.test.ts +++ b/libraries/expo-iap/example/__tests__/vega-runtime.test.ts @@ -29,6 +29,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'receipt-1', store: 'amazon', + storeId: 'amazon', } as Purchase, 'receipt-1', 'http://localhost:3100', @@ -52,6 +53,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'token-1', store: 'google', + storeId: 'play', } as Purchase, 'token-1', 'http://localhost:3100', @@ -73,6 +75,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'purchase-id-1', store: 'horizon', + storeId: 'horizon', } as Purchase, 'purchase-id-1', 'http://localhost:3100', @@ -111,6 +114,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'token-1', store: 'google', + storeId: 'play', } as Purchase, 'token-1', baseUrl, @@ -138,6 +142,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }, }, 'dev.hyo.martie.10bulbs', @@ -156,6 +161,7 @@ describe('Vega runtime example helpers', () => { isValid: true, state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }, }, 'dev.hyo.martie.10bulbs', @@ -175,6 +181,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }, }, 'dev.hyo.martie.10bulbs', @@ -193,6 +200,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', @@ -209,6 +217,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', @@ -226,6 +235,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state, store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', diff --git a/libraries/expo-iap/example/app/offer-code.tsx b/libraries/expo-iap/example/app/offer-code.tsx index f662a985d..b936fd68d 100644 --- a/libraries/expo-iap/example/app/offer-code.tsx +++ b/libraries/expo-iap/example/app/offer-code.tsx @@ -15,7 +15,6 @@ import {openRedeemOfferCode, useIAP} from 'expo-iap'; const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; -// Platform-specific content helpers const getPlatformContent = () => { if (isVegaOS()) { return { @@ -32,17 +31,19 @@ const getPlatformContent = () => { const isIOS = Platform.OS === 'ios'; return { - buttonText: isIOS ? '🎁 Redeem Offer Code' : '🎁 Open Play Store', - buttonSubtext: isIOS ? 'Enter code in-app' : 'Redeem in Play Store', + buttonText: isIOS ? '🎁 Redeem Offer Code' : '🎁 Open Redemption Flow', + buttonSubtext: isIOS + ? 'Enter code in-app' + : 'Redeem with the selected store', howItWorks: isIOS ? '• Tap the button below to open the redemption sheet\n• Enter your offer code\n• The system will validate and apply the code\n• Your purchase will appear in purchase history' - : '• Tap the button to open Google Play Store\n• Enter your promo code in the Play Store\n• Complete the redemption process\n• Return to this app to see your purchase', + : '• Open the selected store’s redemption flow\n• Enter a code if the store supports redemption\n• Refresh available purchases after completing redemption', platformNote: isIOS ? 'iOS supports in-app code redemption via StoreKit' - : 'Android requires redemption through Google Play Store', + : 'Android redemption support depends on the selected store', testingInfo: isIOS ? '• Use TestFlight or App Store Connect to generate test codes\n• Test on real devices (not simulators)\n• Sandbox environment supports offer codes' - : '• Generate promo codes in Google Play Console\n• Test with your Google account\n• Ensure app is properly configured for IAP', + : '• Use the selected store’s test codes and test account\n• Follow that provider’s sandbox instructions', }; }; @@ -70,8 +71,6 @@ export default function OfferCodeScreen() { setIsRedeeming(true); try { - // Unified API: iOS presents the redemption sheet, Android opens the - // Play Store redeem page. Resolves the redeemed purchase or null. const purchase = await openRedeemOfferCode(); if (purchase) { Alert.alert( @@ -86,7 +85,7 @@ export default function OfferCodeScreen() { } else { Alert.alert( 'Redemption Requested', - 'Google Play opens its redeem page; stores without one open nothing. Refresh available purchases after redeeming.', + 'The selected store opens its redemption flow when supported. Refresh available purchases after redeeming.', ); } } catch (error) { diff --git a/libraries/expo-iap/example/bun.lock b/libraries/expo-iap/example/bun.lock index 9169efd59..be49f5114 100644 --- a/libraries/expo-iap/example/bun.lock +++ b/libraries/expo-iap/example/bun.lock @@ -728,7 +728,7 @@ "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], - "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + "compression": ["compression@1.8.2", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -1110,7 +1110,7 @@ "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="], - "joi": ["joi@17.13.7", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ=="], + "joi": ["joi@17.13.8", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1494,7 +1494,7 @@ "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], diff --git a/libraries/expo-iap/example/osv-scanner.toml b/libraries/expo-iap/example/osv-scanner.toml index a2725244d..bc9e3dcc8 100644 --- a/libraries/expo-iap/example/osv-scanner.toml +++ b/libraries/expo-iap/example/osv-scanner.toml @@ -1,14 +1,29 @@ +[[IgnoredVulns]] +id = "GHSA-86w9-cpqp-85rv" +ignoreUntil = 2026-10-30 +reason = "No patched node-forge release is available. This example uses Expo's Node CLI for development and builds; its mobile entry points do not import the CLI certificate tools. Development certificate verification remains affected: https://github.com/advisories/GHSA-86w9-cpqp-85rv." + [[IgnoredVulns]] id = "GHSA-5p2g-fcmc-qvqq" -ignoreUntil = 2026-10-14 -reason = "OSV still records no fixed image-size release for this advisory; Metro uses it only during trusted local builds." +ignoreUntil = 2026-10-30 +reason = "image-size reaches this lock only through the nested Metro 0.76.9 under the stale @react-native-community/cli 11.4.1 and @react-native/metro-config 0.72.12 peer resolutions; re-resolving them drops it. Metro uses it only during trusted local builds." [[IgnoredVulns]] id = "GHSA-w3rx-r6r6-pgpr" -ignoreUntil = 2026-10-14 -reason = "OSV still records no fixed image-size release for this advisory; Metro uses it only during trusted local builds." +ignoreUntil = 2026-10-30 +reason = "image-size reaches this lock only through the nested Metro 0.76.9 under the stale @react-native-community/cli 11.4.1 and @react-native/metro-config 0.72.12 peer resolutions; re-resolving them drops it. Metro uses it only during trusted local builds." [[IgnoredVulns]] id = "GHSA-vcc3-ghjq-m6fr" ignoreUntil = 2026-12-15 reason = "decode-uri-component 0.5.0 is the only patched release and is ESM-only, so query-string 7 cannot take it; query-string 9, which can, is ESM-only too. expo-router 57.0.21 still depends on query-string ^7.1.3, so this example app cannot break the chain. Revisit when expo-router drops query-string 7, as @react-navigation/core did in 7.22.1." + +[[IgnoredVulns]] +id = "GHSA-vfj7-8cjw-p6xm" +ignoreUntil = 2026-10-30 +reason = "No patched braces release exists. It is reached only by the example's build and development tooling that parses repository-controlled glob patterns; its mobile entry points do not evaluate untrusted patterns: https://github.com/advisories/GHSA-vfj7-8cjw-p6xm." + +[[IgnoredVulns]] +id = "GHSA-hp3w-g68c-fv3c" +ignoreUntil = 2026-10-30 +reason = "No patched sprintf-js release exists; 1.1.3 is both npm latest and still affected. It is reached only via argparse 1 and js-yaml 3 in Metro, React Native CLI, and Jest coverage tooling; the example's mobile entry points do not import it: https://github.com/advisories/GHSA-hp3w-g68c-fv3c." diff --git a/libraries/expo-iap/example/src/utils/buildPurchaseRows.ts b/libraries/expo-iap/example/src/utils/buildPurchaseRows.ts index 42b0a19ac..c6d408609 100644 --- a/libraries/expo-iap/example/src/utils/buildPurchaseRows.ts +++ b/libraries/expo-iap/example/src/utils/buildPurchaseRows.ts @@ -75,6 +75,7 @@ export const buildPurchaseRows = (purchase: Purchase): PurchaseDetailRow[] => { pushRow(rows, 'transactionId', transactionId); pushRow(rows, 'productId', purchase.productId); pushRow(rows, 'store', purchase.store); + pushRow(rows, 'storeId', purchase.storeId); pushRow(rows, 'ids', formatList(purchase.ids ?? undefined)); pushRow(rows, 'transactionDate', formatDate(purchase.transactionDate)); pushRow(rows, 'purchaseState', normalizedState); diff --git a/libraries/expo-iap/example/vega/bun.lock b/libraries/expo-iap/example/vega/bun.lock index 670e53c0e..ace75f345 100644 --- a/libraries/expo-iap/example/vega/bun.lock +++ b/libraries/expo-iap/example/vega/bun.lock @@ -505,7 +505,7 @@ "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], - "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + "compression": ["compression@1.8.2", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -701,7 +701,7 @@ "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], - "joi": ["joi@17.13.7", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ=="], + "joi": ["joi@17.13.8", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], diff --git a/libraries/expo-iap/example/vega/osv-scanner.toml b/libraries/expo-iap/example/vega/osv-scanner.toml index 36ebb8a8d..8a4244c67 100644 --- a/libraries/expo-iap/example/vega/osv-scanner.toml +++ b/libraries/expo-iap/example/vega/osv-scanner.toml @@ -1,9 +1,19 @@ [[IgnoredVulns]] id = "GHSA-5p2g-fcmc-qvqq" -ignoreUntil = 2026-10-14 -reason = "No patched image-size release exists; Metro uses it only during trusted Vega builds." +ignoreUntil = 2026-10-30 +reason = "The fix is image-size 2.0.3, but Metro pins ^1.0.2 so the lock cannot take it; Metro uses it only during trusted Vega builds." [[IgnoredVulns]] id = "GHSA-w3rx-r6r6-pgpr" -ignoreUntil = 2026-10-14 -reason = "No patched image-size release exists; Metro uses it only during trusted Vega builds." +ignoreUntil = 2026-10-30 +reason = "The fix is image-size 2.0.3, but Metro pins ^1.0.2 so the lock cannot take it; Metro uses it only during trusted Vega builds." + +[[IgnoredVulns]] +id = "GHSA-vfj7-8cjw-p6xm" +ignoreUntil = 2026-10-30 +reason = "No patched braces release exists. It is reached only by build tooling that parses repository-controlled glob patterns during trusted Vega builds: https://github.com/advisories/GHSA-vfj7-8cjw-p6xm." + +[[IgnoredVulns]] +id = "GHSA-hp3w-g68c-fv3c" +ignoreUntil = 2026-10-30 +reason = "No patched sprintf-js release exists; 1.1.3 is both npm latest and still affected. It is reached only via argparse 1 and js-yaml 3 in Metro and React Native CLI config tooling during trusted Vega builds: https://github.com/advisories/GHSA-hp3w-g68c-fv3c." diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index 3fba69db5..f91cfea8f 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -19,6 +19,10 @@ public final class ExpoIapModule: Module { OpenIapSerialization.errorCodes() } + Constant("HAS_EXPLICIT_STORE_PROVIDER") { + Bundle.main.object(forInfoDictionaryKey: OpenIapProvider.metadataKey) != nil + } + Events( IapEvent.purchaseUpdated.rawValue, IapEvent.purchaseError.rawValue, @@ -330,6 +334,11 @@ public final class ExpoIapModule: Module { return storefront } + AsyncFunction("restorePurchases") { () async throws -> Bool in + try await OpenIapModule.shared.restorePurchases() + return true + } + AsyncFunction("syncIOS") { () async throws -> Bool in ExpoIapLog.payload("syncIOS", payload: nil) let success = try await OpenIapModule.shared.syncIOS() diff --git a/libraries/expo-iap/ios/onside/OnsideIapModule.swift b/libraries/expo-iap/ios/onside/OnsideIapModule.swift index ee80bfb0a..6e991b1a2 100644 --- a/libraries/expo-iap/ios/onside/OnsideIapModule.swift +++ b/libraries/expo-iap/ios/onside/OnsideIapModule.swift @@ -506,6 +506,7 @@ public final class ExpoIapOnsideModule: Module { // dedicated IapStore enum value. Preserve the required store // discriminator without reporting the purchase as App Store traffic. dictionary["store"] = "unknown" + dictionary["storeId"] = "onside" if product.subscriptionPeriod == nil { dictionary["currentPlanId"] = NSNull() } else { diff --git a/libraries/expo-iap/osv-scanner.toml b/libraries/expo-iap/osv-scanner.toml new file mode 100644 index 000000000..3627efa64 --- /dev/null +++ b/libraries/expo-iap/osv-scanner.toml @@ -0,0 +1,14 @@ +[[IgnoredVulns]] +id = "GHSA-86w9-cpqp-85rv" +ignoreUntil = 2026-10-30 +reason = "No patched node-forge release is available. Expo's development CLI uses it for certificate tooling; the published SDK and config plugin do not import it. Development certificate verification remains affected: https://github.com/advisories/GHSA-86w9-cpqp-85rv." + +[[IgnoredVulns]] +id = "GHSA-vfj7-8cjw-p6xm" +ignoreUntil = 2026-10-30 +reason = "No patched braces release exists. It is reached only by build, test, and CLI tooling that parses repository-controlled glob patterns; the published SDK package declares no runtime npm dependencies: https://github.com/advisories/GHSA-vfj7-8cjw-p6xm." + +[[IgnoredVulns]] +id = "GHSA-hp3w-g68c-fv3c" +ignoreUntil = 2026-10-30 +reason = "No patched sprintf-js release exists; 1.1.3 is both npm latest and still affected. It is reached only via argparse 1 and js-yaml 3 in Jest coverage tooling; the published SDK package declares no runtime npm dependencies: https://github.com/advisories/GHSA-hp3w-g68c-fv3c." diff --git a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts index 62e715880..df16fce4a 100644 --- a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts +++ b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts @@ -10,6 +10,7 @@ import plugin, { resolveAlternativeBillingIOS, resolveAmazonAppstoreKey, resolveAmazonPlatformFlags, + resolveAndroidStoreSelection, resolveHorizonAppId, resolveModuleSelection, resolvePinnedAndroidStore, @@ -552,6 +553,288 @@ describe('android configuration', () => { ]); }); + it('pairs a community store with its provider and removes both on auto', () => { + const properties = [ + {type: 'property', key: 'openiapProvider', value: 'old:provider:1.0.0'}, + ]; + const result = storeGradleProperties( + properties, + 'fixture', + 'dev.example:provider:1.0.0', + ); + expect(result).toEqual([ + {type: 'property', key: 'openiapStore', value: 'fixture'}, + { + type: 'property', + key: 'openiapProvider', + value: 'dev.example:provider:1.0.0', + }, + ]); + expect(storeGradleProperties(result, null)).toEqual([]); + }); + + it('leaves the pin to the module flag without an android.store', () => { + expect(resolveAndroidStoreSelection(undefined, null)).toEqual({ + pinnedStore: null, + provider: undefined, + }); + expect(resolveAndroidStoreSelection({}, 'amazon')).toEqual({ + pinnedStore: 'amazon', + provider: undefined, + }); + expect( + resolveAndroidStoreSelection({android: {store: 'auto'}}, 'amazon'), + ).toEqual({pinnedStore: 'amazon', provider: undefined}); + }); + + it('pins an official store id', () => { + expect( + resolveAndroidStoreSelection({android: {store: 'horizon'}}, null), + ).toEqual({pinnedStore: 'horizon', provider: undefined}); + }); + + it.each([ + ['google', 'play'], + ['gplay', 'play'], + ['googleplay', 'play'], + ['google-play', 'play'], + ['gms', 'play'], + ['meta', 'horizon'], + ['quest', 'horizon'], + ['fire', 'amazon'], + ['fireos', 'amazon'], + ['fire-os', 'amazon'], + ])( + 'resolves the %s alias to %s without provider coordinates', + (alias, canonical) => { + expect( + resolveAndroidStoreSelection({android: {store: alias}}, null), + ).toEqual({pinnedStore: canonical, provider: undefined}); + }, + ); + + it('trims and lowercases the store id before resolving it', () => { + expect( + resolveAndroidStoreSelection({android: {store: ' Google '}}, null), + ).toEqual({pinnedStore: 'play', provider: undefined}); + }); + + it('accepts a community id with provider coordinates', () => { + expect( + resolveAndroidStoreSelection( + { + android: { + store: 'fixture', + provider: 'dev.example:provider:1.0.0', + }, + }, + null, + ), + ).toEqual({ + pinnedStore: 'fixture', + provider: 'dev.example:provider:1.0.0', + }); + }); + + it('rejects provider coordinates without a store id', () => { + expect(() => + resolveAndroidStoreSelection( + {android: {provider: 'dev.example:provider:1.0.0'}}, + null, + ), + ).toThrow( + 'expo-iap: android.provider requires a community android.store id', + ); + }); + + it.each([ + ['play'], + ['google'], + ['horizon'], + ['quest'], + ['amazon'], + ['fire-os'], + ['auto'], + ])('rejects provider coordinates with the official %s store', (store) => { + expect(() => + resolveAndroidStoreSelection( + { + android: {store, provider: 'dev.example:provider:1.0.0'}, + }, + null, + ), + ).toThrow( + 'expo-iap: android.provider requires a community android.store id', + ); + }); + + it.each([['apple'], ['none'], ['unknown']])( + 'rejects the reserved %s store id', + (store) => { + expect(() => + resolveAndroidStoreSelection({android: {store}}, null), + ).toThrow( + `expo-iap: android.store=${store} is not an Android store id; use play, horizon, amazon, or auto, or a community id`, + ); + }, + ); + + it('rejects a reserved id even with provider coordinates', () => { + // Coordinates cannot fix a reserved id, so the error must not ask for them. + expect(() => + resolveAndroidStoreSelection( + { + android: {store: 'apple', provider: 'dev.example:provider:1.0.0'}, + }, + null, + ), + ).toThrow( + 'expo-iap: android.store=apple is not an Android store id; use play, horizon, amazon, or auto, or a community id', + ); + }); + + it.each([['1store'], ['has space'], ['upper!'], ['a..b']])( + 'rejects the invalid %s store id grammar', + (store) => { + expect(() => + resolveAndroidStoreSelection({android: {store}}, null), + ).toThrow( + `expo-iap: android.store=${store} is not a store id; use play, horizon, amazon, or auto, or a lowercase community id`, + ); + }, + ); + + it('rejects malformed grammar even with provider coordinates', () => { + expect(() => + resolveAndroidStoreSelection( + { + android: {store: '1store', provider: 'dev.example:provider:1.0.0'}, + }, + null, + ), + ).toThrow( + 'expo-iap: android.store=1store is not a store id; use play, horizon, amazon, or auto, or a lowercase community id', + ); + }); + + it('rejects a community id without provider coordinates', () => { + expect(() => + resolveAndroidStoreSelection({android: {store: 'fixture'}}, null), + ).toThrow( + 'expo-iap: a community android.store requires android.provider coordinates', + ); + }); + + it.each([ + ['nogroup'], + ['group:artifact'], + ['group:artifact:xyz'], + ['group:artifact:1.0:extra'], + ])('rejects the invalid %s provider coordinates', (provider) => { + expect(() => + resolveAndroidStoreSelection( + {android: {store: 'fixture', provider}}, + null, + ), + ).toThrow( + 'expo-iap: android.provider must be fixed group:artifact:version coordinates', + ); + }); + + it('rejects official-artifact provider coordinates', () => { + expect(() => + resolveAndroidStoreSelection( + { + android: { + store: 'fixture', + provider: 'io.github.hyochan.openiap:openiap-google:1.0.0', + }, + }, + null, + ), + ).toThrow( + 'expo-iap: android.provider must be fixed group:artifact:version coordinates', + ); + }); + + it('rejects a store pin that conflicts with the module pin', () => { + expect(() => + resolveAndroidStoreSelection({android: {store: 'play'}}, 'amazon'), + ).toThrow( + 'expo-iap: android.store=play conflicts with the amazon module pin', + ); + expect(() => + resolveAndroidStoreSelection({android: {store: 'google'}}, 'amazon'), + ).toThrow( + 'expo-iap: android.store=play conflicts with the amazon module pin', + ); + }); + + it('accepts an alias that matches the module pin', () => { + expect( + resolveAndroidStoreSelection({android: {store: 'fire'}}, 'amazon'), + ).toEqual({pinnedStore: 'amazon', provider: undefined}); + }); + + it('fails the prebuild on an alias with provider coordinates', () => { + expect(() => + plugin({name: 'app', slug: 'app'} as ExpoConfig, { + android: {store: 'google', provider: 'dev.example:provider:1.0.0'}, + }), + ).toThrow( + 'expo-iap: android.provider requires a community android.store id', + ); + }); + + it('fails the prebuild on an alias that conflicts with the module pin', () => { + expect(() => + plugin({name: 'app', slug: 'app'} as ExpoConfig, { + modules: {amazon: {fireOS: true}}, + android: {store: 'google'}, + }), + ).toThrow( + 'expo-iap: android.store=play conflicts with the amazon module pin', + ); + }); + + it('pins the canonical store for an alias on prebuild', async () => { + const fs = jest.requireActual('fs') as typeof import('fs'); + const os = jest.requireActual('os') as typeof import('os'); + const path = jest.requireActual('path') as typeof import('path'); + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'expo-iap-store-'), + ); + try { + const android = path.join(projectRoot, 'android'); + fs.mkdirSync(path.join(android, 'app', 'src', 'main'), {recursive: true}); + fs.writeFileSync( + path.join(android, 'settings.gradle'), + "rootProject.name = 'app'\ninclude ':app'\n", + ); + fs.writeFileSync(path.join(android, 'build.gradle'), rootBuild); + fs.writeFileSync(path.join(android, 'app', 'build.gradle'), appBuild); + fs.writeFileSync( + path.join(android, 'gradle.properties'), + 'org.gradle.jvmargs=-Xmx2g\n', + ); + fs.writeFileSync( + path.join(android, 'app', 'src', 'main', 'AndroidManifest.xml'), + '\n \n\n', + ); + await compileModsAsync( + plugin({name: 'app', slug: 'app'} as ExpoConfig, { + android: {store: 'google'}, + }) as ExpoConfig, + {projectRoot, platforms: ['android']}, + ); + expect( + fs.readFileSync(path.join(android, 'gradle.properties'), 'utf8'), + ).toContain('openiapStore=play'); + } finally { + fs.rmSync(projectRoot, {recursive: true, force: true}); + } + }); + it('reads the Amazon Appstore key path from android.amazon', () => { expect( resolveAmazonAppstoreKey({ @@ -1101,7 +1384,7 @@ describe('local OpenIAP configuration', () => { describe('ios module selection', () => { const createConfig = (ios?: ExpoConfig['ios']): ExpoConfig => - ({name: 'test-app', slug: 'test-app', ios} as ExpoConfig); + ({name: 'test-app', slug: 'test-app', ios}) as ExpoConfig; it('defaults to Expo IAP only when no options provided', () => { const result = resolveModuleSelection(createConfig(), undefined); diff --git a/libraries/expo-iap/plugin/src/__tests__/withLocalOpenIAP.test.ts b/libraries/expo-iap/plugin/src/__tests__/withLocalOpenIAP.test.ts index ca7503812..f983a19a9 100644 --- a/libraries/expo-iap/plugin/src/__tests__/withLocalOpenIAP.test.ts +++ b/libraries/expo-iap/plugin/src/__tests__/withLocalOpenIAP.test.ts @@ -32,7 +32,7 @@ describe('ensureLocalOpenIapFlavorStrategy', () => { expect(result).toContain(`apply from: "${scriptPath}"`); expect(result).toContain( - 'def openIapStore = openIapResolveStore("expo-iap").store', + 'def openIapStore = openIapResolveStore("expo-iap").platform', ); expect(result).toContain('subprojects { subproject ->'); // The app module is an application, not a library, and needs the strategy too. @@ -87,7 +87,7 @@ describe('ensureLocalOpenIapFlavorStrategy', () => { // the root computed. const groovy = appStoreLines('../x/openiap-store.gradle', 'groovy'); expect(groovy.apply).toBe('apply from: "../x/openiap-store.gradle"'); - expect(groovy.strategy).toContain('openIapResolveStore("app").store'); + expect(groovy.strategy).toContain('openIapResolveStore("app").platform'); expect(groovy.strategy).not.toContain('rootProject'); const kotlin = appStoreLines('../x/openiap-store.gradle', 'kt'); diff --git a/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts b/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts index cc5852a23..4e5309e05 100644 --- a/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts +++ b/libraries/expo-iap/plugin/src/expoConfig.augmentation.d.ts @@ -66,6 +66,10 @@ type BaseExpoIapOptions = { alternativeBilling?: IOSAlternativeBillingConfig; }; android?: { + /** Android store id; pair a community id with provider coordinates. */ + store?: string; + /** Fixed Maven coordinates (group:artifact:version) for a community provider. */ + provider?: string; /** * Horizon OS options for Quest devices. * @platform android @@ -110,8 +114,7 @@ type ExplicitModuleOptions = BaseExpoIapOptions & { }; export type ExpoIapPluginCommonOptions = - | AutoModuleOptions - | ExplicitModuleOptions; + AutoModuleOptions | ExplicitModuleOptions; declare module '@expo/config-types' { interface IOS { diff --git a/libraries/expo-iap/plugin/src/withIAP.ts b/libraries/expo-iap/plugin/src/withIAP.ts index a7a93e9f0..4d92f3369 100644 --- a/libraries/expo-iap/plugin/src/withIAP.ts +++ b/libraries/expo-iap/plugin/src/withIAP.ts @@ -207,21 +207,42 @@ export const modifyAppBuildGradle = ( return modified; }; -export type AndroidStorePin = 'horizon' | 'amazon' | null; +export type AndroidStorePin = string | null; const STORE_PROPERTY_KEYS = [ 'openiapStore', + 'openiapProvider', 'openiapPlatform', 'horizonEnabled', 'fireOsEnabled', ]; +// Same table as packages/google/gradle/openiap-store.gradle. +const ANDROID_STORE_ALIASES: Record = { + "play": "play", + "google": "play", + "gplay": "play", + "googleplay": "play", + "google-play": "play", + "gms": "play", + "horizon": "horizon", + "meta": "horizon", + "quest": "horizon", + "amazon": "amazon", + "fire": "amazon", + "fireos": "amazon", + "fire-os": "amazon", + "auto": "auto", + "none": "none", +}; + type GradleProperty = {type: string; key?: string; value?: string}; // A pin outranks everything else, so a key an earlier prebuild left is removed. export function storeGradleProperties( properties: T[], pinnedStore: AndroidStorePin, + provider?: string, ): T[] { const kept = properties.filter( (item) => @@ -249,6 +270,9 @@ export function storeGradleProperties( ? [ ...kept, {type: 'property', key: 'openiapStore', value: pinnedStore} as T, + ...(provider + ? [{type: 'property', key: 'openiapProvider', value: provider} as T] + : []), ] : kept; } @@ -298,6 +322,7 @@ const withIapAndroid: ConfigPlugin< { horizonAppId?: string; pinnedStore?: AndroidStorePin; + provider?: string; amazonAppstoreKey?: string; } | void > = (config, props) => { @@ -323,7 +348,11 @@ const withIapAndroid: ConfigPlugin< }); config = withGradleProperties(config, (config) => { - config.modResults = storeGradleProperties(config.modResults, pinnedStore); + config.modResults = storeGradleProperties( + config.modResults, + pinnedStore, + props?.provider, + ); logOnce( pinnedStore ? `✅ expo-iap: Set openiapStore=${pinnedStore} in gradle.properties` @@ -683,12 +712,12 @@ export function resolveAmazonPlatformFlags( // outranks the manifest.toml guess, so Vega stays off unless declared too. const vegaOverride = hasOwnKey(moduleAmazon, 'vegaOS') ? moduleAmazon?.vegaOS === true - : resolveVegaProjectOptions(options)?.enabled ?? + : (resolveVegaProjectOptions(options)?.enabled ?? (readEnvFlag('EXPO_IAP_VEGA', envFlagsUsed) ? true : isFireOsEnabled - ? false - : undefined); + ? false + : undefined)); const modules = options?.modules; // Both flags are reported so resolvePinnedAndroidStore can refuse the pair. const isHorizonEnabled = hasOwnKey(modules, 'horizon') @@ -725,8 +754,69 @@ export function resolvePinnedAndroidStore( return flags.isFireOsEnabled ? 'amazon' : flags.isHorizonEnabled - ? 'horizon' - : null; + ? 'horizon' + : null; +} + +export type AndroidStoreSelection = { + pinnedStore: AndroidStorePin; + provider?: string; +}; + +// A registry alias behaves as its official store; a community id needs provider coordinates. +export function resolveAndroidStoreSelection( + options: ExpoIapPluginOptions | void, + legacyPin: AndroidStorePin, +): AndroidStoreSelection { + const requestedStore = options?.android?.store?.trim().toLowerCase(); + const provider = options?.android?.provider?.trim(); + const normalizedStore = + requestedStore == null + ? undefined + : (ANDROID_STORE_ALIASES[requestedStore] ?? requestedStore); + const official = ['play', 'horizon', 'amazon', 'auto']; + if (provider && (!normalizedStore || official.includes(normalizedStore))) { + throw new Error( + 'expo-iap: android.provider requires a community android.store id', + ); + } + // Each refusal names its own fix: a reserved id is never valid here, a + // malformed id must be respelled, and only a well-formed community id is + // fixed by adding coordinates. + if (normalizedStore && !official.includes(normalizedStore)) { + if (['apple', 'none', 'unknown'].includes(normalizedStore)) { + throw new Error( + `expo-iap: android.store=${normalizedStore} is not an Android store id; use play, horizon, amazon, or auto, or a community id`, + ); + } + if (!/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(normalizedStore)) { + throw new Error( + `expo-iap: android.store=${normalizedStore} is not a store id; use play, horizon, amazon, or auto, or a lowercase community id`, + ); + } + if (!provider) { + throw new Error( + 'expo-iap: a community android.store requires android.provider coordinates', + ); + } + } + if ( + provider && + (!/^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[0-9][A-Za-z0-9_.-]*$/.test(provider) || + provider.startsWith('io.github.hyochan.openiap:openiap-')) + ) { + throw new Error( + 'expo-iap: android.provider must be fixed group:artifact:version coordinates', + ); + } + const pinnedStore = + normalizedStore && normalizedStore !== 'auto' ? normalizedStore : legacyPin; + if (legacyPin && pinnedStore !== legacyPin) { + throw new Error( + `expo-iap: android.store=${pinnedStore} conflicts with the ${legacyPin} module pin`, + ); + } + return {pinnedStore, provider}; } const REMOVED_IN = `removed in the next major release (expo-iap ${ @@ -777,8 +867,7 @@ export function resolveModuleSelection( envFlagsUsed: string[] = [], ): ModuleSelectionResult { const normalizedOptions = (options ?? undefined) as - | ExpoIapPluginCommonOptions - | undefined; + ExpoIapPluginCommonOptions | undefined; const selection = normalizedOptions?.module ?? 'auto'; @@ -814,10 +903,14 @@ const withIap: ConfigPlugin = ( const {isFireOsEnabled, vegaOverride, isHorizonEnabled} = resolveAmazonPlatformFlags(options, envFlagsUsed); // Outside the try, whose catch would turn this error into a warning. - const pinnedStore = resolvePinnedAndroidStore({ + const legacyPin = resolvePinnedAndroidStore({ isFireOsEnabled, isHorizonEnabled, }); + const {pinnedStore, provider} = resolveAndroidStoreSelection( + options, + legacyPin, + ); try { // Add iapkitApiKey to extra if provided @@ -883,6 +976,7 @@ const withIap: ConfigPlugin = ( let result = withIapAndroid(config, { horizonAppId, pinnedStore, + provider, amazonAppstoreKey, }); diff --git a/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts b/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts index c067100d1..8c126d45b 100644 --- a/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts +++ b/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts @@ -121,13 +121,13 @@ export const appStoreLines = ( ? { // defaultConfig's receiver is DefaultConfig, not the script, so read // extra at the top level where the script scope applies. - apply: `apply(from = "${storeScriptPath}")\nval openIapStore = ((extra["openIapResolveStore"] as groovy.lang.Closure<*>).call("app") as Map<*, *>)["store"] as String`, + apply: `apply(from = "${storeScriptPath}")\nval openIapStore = ((extra["openIapResolveStore"] as groovy.lang.Closure<*>).call("app") as Map<*, *>)["platform"] as String`, strategy: ' missingDimensionStrategy("platform", openIapStore)', } : { apply: `apply from: "${storeScriptPath}"`, strategy: - ' missingDimensionStrategy "platform", openIapResolveStore("app").store', + ' missingDimensionStrategy "platform", openIapResolveStore("app").platform', }; // Each removal also takes the blank line written beside the line, so a switch @@ -148,18 +148,18 @@ export const removeLocalOpenIapFlavorStrategy = (contents: string): string => export const removeLocalOpenIapSettings = (contents: string): string => contents .replace( - /(?:^[ \t]*\n)?^[ \t]*include[ \t]*\(?[ \t]*["']:openiap-google["'][ \t]*\)?[ \t]*\n?/gm, + /(?:^[ \t]*\n)?^[ \t]*include[ \t]*\(?[ \t]*["']:openiap-(?:google|core)["'][ \t]*\)?[ \t]*\n?/gm, '', ) .replace( - /^[ \t]*project\(["']:openiap-google["']\)\.projectDir[ \t]*=.*\n?/gm, + /^[ \t]*project\(["']:openiap-(?:google|core)["']\)\.projectDir[ \t]*=.*\n?/gm, '', ); export const removeLocalOpenIapAppWiring = (contents: string): string => contents .replace( - /^[ \t]*implementation[ \t]*\(?[ \t]*project\([ \t]*["']:openiap-google["'][ \t]*\)[ \t]*\)?[ \t]*\n?/gm, + /^[ \t]*(?:if \(!project\.hasProperty\(["']openiapProvider["']\)\) )?implementation[ \t]*\(?[ \t]*project\([ \t]*["']:openiap-(?:google|core)["'][ \t]*\)[ \t]*\)?[ \t]*\n?/gm, '', ) .replace( @@ -197,7 +197,7 @@ export const ensureLocalOpenIapFlavorStrategy = ( language === 'kt' ? `apply(from = "${storeScriptPath}") val openIapStore = - ((extra["openIapResolveStore"] as groovy.lang.Closure<*>).call("expo-iap") as Map<*, *>)["store"] as String + ((extra["openIapResolveStore"] as groovy.lang.Closure<*>).call("expo-iap") as Map<*, *>)["platform"] as String project(":openiap-google") { layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("openiap-google")) @@ -215,7 +215,7 @@ ${LOCAL_STRATEGY_LINE_KOTLIN} } }` : `apply from: "${storeScriptPath}" -def openIapStore = openIapResolveStore("expo-iap").store +def openIapStore = openIapResolveStore("expo-iap").platform project(":openiap-google") { layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("openiap-google")) @@ -479,6 +479,25 @@ const withLocalOpenIAP: ConfigPlugin< } else if (!contents.includes(projectDirLine)) { contents += `${projectDirLine}\n`; } + const corePath = path.posix.join(relativeAndroidModulePath, '..', 'core'); + const coreInclude = + settingsLanguage === 'kt' + ? 'include(":openiap-core")' + : "include ':openiap-core'"; + const coreProject = + settingsLanguage === 'kt' + ? `project(":openiap-core").projectDir = File(settingsDir, "${corePath}")` + : `project(':openiap-core').projectDir = new File(settingsDir, '${corePath}')`; + contents = contents + .replace( + /^\s*include[ \t]*(?:\([ \t]*)?["']:openiap-core["'][ \t]*\)?[ \t]*\n?/gm, + '', + ) + .replace( + /^\s*project\(["']:openiap-core["']\)\.projectDir[^\n]*\n?/gm, + '', + ); + contents += `\n${coreInclude}\n${coreProject}\n`; settings.contents = contents; logOnce(`✅ Linked local Android module at: ${androidModulePath}`); return config; @@ -497,8 +516,8 @@ const withLocalOpenIAP: ConfigPlugin< const appLanguage = gradle.language; const dependencyLine = appLanguage === 'kt' - ? ` implementation(project(":openiap-google"))` - : ` implementation project(':openiap-google')`; + ? ` if (!project.hasProperty("openiapProvider")) implementation(project(":openiap-google"))` + : ` if (!project.hasProperty('openiapProvider')) implementation project(':openiap-google')`; let contents = gradle.contents; // `:app` runs before the root build file, so it applies the resolver itself. diff --git a/libraries/expo-iap/src/ExpoIapModule.ts b/libraries/expo-iap/src/ExpoIapModule.ts index 29d50ff93..f9b2a9f3b 100644 --- a/libraries/expo-iap/src/ExpoIapModule.ts +++ b/libraries/expo-iap/src/ExpoIapModule.ts @@ -26,6 +26,7 @@ const ONSIDE_MARKETPLACE_ID = 'com.onside.marketplace-app'; /** Members read from the raw module; every store module provides them. */ type NativeEventModule = { ERROR_CODES?: Record; + HAS_EXPLICIT_STORE_PROVIDER?: boolean; addListener( eventName: string, listener: (payload: T) => void, @@ -93,6 +94,7 @@ export type ExpoIapNativeModule = NativeEventModule & getAllTransactionsIOS(): Promise; getPendingTransactionsIOS(): Promise; showManageSubscriptionsIOS(): Promise; + deepLinkToSubscriptionsIOS(): Promise; requestPurchase(request: object): Promise; finishTransaction( purchase: PurchaseInput, @@ -123,9 +125,28 @@ type ResolvedNativeModule = { let cached: ResolvedNativeModule | null = null; let onsideModuleUnavailable = false; +let explicitStoreProvider: boolean | undefined; function getResolved(): ResolvedNativeModule { function shouldUseOnsideModule(): boolean { + if ( + installedFromOnside === true || + typeof installedFromOnside === 'string' + ) { + if (explicitStoreProvider === undefined) { + try { + const module = + cached?.name === 'ExpoIap' + ? cached.module + : requireNativeModule('ExpoIap'); + explicitStoreProvider = module.HAS_EXPLICIT_STORE_PROVIDER === true; + } catch (error) { + if (!isMissingModuleError(error, 'ExpoIap')) throw error; + explicitStoreProvider = false; + } + } + if (explicitStoreProvider) return false; + } if (installedFromOnside === true) { return true; } diff --git a/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts b/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts index dcada385d..1b4cd2f0f 100644 --- a/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts +++ b/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts @@ -9,6 +9,28 @@ describe('ExpoIapModule proxy', () => { return require('../ExpoIapModule').default; }; + it('uses an explicit native provider before automatic Onside selection', () => { + const module = { + HAS_EXPLICIT_STORE_PROVIDER: true, + fetchProducts: jest.fn(), + }; + const requireNativeModule = jest.fn((name: string) => { + if (name === 'ExpoIap') return module; + throw new Error(`Unexpected native module '${name}'`); + }); + jest.doMock('../onside', () => ({installedFromOnside: true})); + jest.doMock('expo-modules-core', () => ({ + requireNativeModule, + UnavailabilityError: class extends Error {}, + })); + const resolved = loadExpoIapModule(); + expect(resolved.USING_ONSIDE_SDK).toBe(false); + expect(resolved.fetchProducts).toBe(module.fetchProducts); + expect( + requireNativeModule.mock.calls.every(([name]) => name === 'ExpoIap'), + ).toBe(true); + }); + it('does not load ExpoIapOnside when Onside is not enabled', () => { const expoIapModule = { ERROR_CODES: {}, @@ -119,6 +141,7 @@ describe('ExpoIapModule proxy', () => { expect(() => ExpoIapModule.USING_ONSIDE_SDK).toThrow(); expect(() => ExpoIapModule.USING_ONSIDE_SDK).toThrow(); expect(requireNativeModule.mock.calls.map(([name]) => name)).toEqual([ + 'ExpoIap', 'ExpoIapOnside', ]); }); @@ -147,6 +170,6 @@ describe('ExpoIapModule proxy', () => { expect(ExpoIapModule.requestPurchase).toBe(onsideModule.requestPurchase); expect(() => ExpoIapModule.verifyPurchase()).toThrow(); expect(() => ExpoIapModule.getActiveSubscriptions()).toThrow(); - expect(requireNativeModule).toHaveBeenCalledTimes(1); + expect(requireNativeModule).toHaveBeenCalledTimes(2); }); }); diff --git a/libraries/expo-iap/src/__tests__/conformance.test.ts b/libraries/expo-iap/src/__tests__/conformance.test.ts index 2b035ec42..178f9ac8a 100644 --- a/libraries/expo-iap/src/__tests__/conformance.test.ts +++ b/libraries/expo-iap/src/__tests__/conformance.test.ts @@ -19,6 +19,9 @@ const CATALOG: Record = { 'dev.hyo.martie.lifetime': 'in-app', }; +// A community provider reports Unknown with its own stable id. +const COMMUNITY_STORE_ID = 'community-fixture'; + const fakeStore = { owned: new Map(), forced: new Map(), @@ -42,6 +45,7 @@ function toPurchase(record: FakeRecord) { isAutoRenewing: record.type === 'subs' && record.state === 'purchased', quantity: 1, store: 'google', + storeId: 'play', platform: 'android', transactionDate: 1_700_000_000_000, transactionId: record.token, @@ -65,6 +69,7 @@ const nativeModule: Record = { isValid: record?.state === 'purchased', productId: record?.sku ?? '', store: 'google', + storeId: 'play', }; }), initConnection: jest.fn(async () => true), @@ -136,7 +141,12 @@ const nativeModule: Record = { state: forced === 'pending' ? 'pending' : 'purchased', }; fakeStore.owned.set(record.token, record); - return toPurchase(record); + const purchase = toPurchase(record); + if (forced === 'community') { + purchase.store = 'unknown'; + purchase.storeId = COMMUNITY_STORE_ID; + } + return purchase; }), getAvailableItems: jest.fn(async () => @@ -396,6 +406,14 @@ describe('conformance: expo-iap', () => { const purchase = await buy('dev.hyo.martie.10bulbs'); expect(purchase.store).toBeTruthy(); expect(purchase.store).not.toBe('unknown'); + expect(purchase.storeId).toBe('play'); + expect(purchase.storeId).toBeTruthy(); + + fakeStore.forced.set('dev.hyo.martie.lifetime', 'community'); + const community = await buy('dev.hyo.martie.lifetime'); + expect(community.store).toBe('unknown'); + expect(community.storeId).toBe(COMMUNITY_STORE_ID); + expect(community.storeId).toBeTruthy(); }); it('identifiers.purchase-token-is-stable-across-reads', async () => { diff --git a/libraries/expo-iap/src/__tests__/index.kepler.test.ts b/libraries/expo-iap/src/__tests__/index.kepler.test.ts index 3f9e4418e..0635b0665 100644 --- a/libraries/expo-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/expo-iap/src/__tests__/index.kepler.test.ts @@ -22,6 +22,7 @@ const vegaPurchase = ( purchaseState: 'purchased', quantity: 1, store: 'amazon', + storeId: 'amazon', transactionDate: 1720000000000, ...overrides, }); @@ -116,6 +117,7 @@ describe('Amazon Vega public API', () => { productId: 'premium', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index 03913363e..5aaa24183 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -55,6 +55,7 @@ const nativePurchase = ( productId: `product.${id}`, transactionDate: 1720000000000, store: 'google', + storeId: 'play', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -419,6 +420,7 @@ describe('Public API (index.ts)', () => { id: 'billing-issue', productId: 'sub.monthly', store: 'apple', + storeId: 'apple', }; registeredCallback?.(purchase); @@ -1266,19 +1268,22 @@ describe('Public API (index.ts)', () => { expect(res).toHaveLength(2); }); - it('restorePurchases performs iOS sync then fetches purchases', async () => { + it('restorePurchases calls the provider then fetches purchases', async () => { Object.assign(Platform, {OS: 'ios'}); - const syncSpy = jest.spyOn(iosMod, 'syncIOS').mockResolvedValue(true); + const restoreSpy = ((ExpoIapModule.restorePurchases as jest.Mock) = jest + .fn() + .mockResolvedValue(true)); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValue([ nativePurchase('legacy', { store: 'apple', + storeId: 'apple', transactionId: 'txn-restore', }), ]); await restorePurchases(); - expect(syncSpy).toHaveBeenCalledTimes(1); + expect(restoreSpy).toHaveBeenCalledTimes(1); expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith(false, true); }); @@ -1297,6 +1302,7 @@ describe('Public API (index.ts)', () => { .mockResolvedValue([ nativePurchase('onside', { store: 'apple', + storeId: 'apple', transactionId: 'txn-onside', }), ]); @@ -1315,6 +1321,24 @@ describe('Public API (index.ts)', () => { } }); + it('getAvailablePurchases preserves community identity on iOS', async () => { + Object.assign(Platform, {OS: 'ios'}); + (ExpoIapModule.getAvailableItems as jest.Mock).mockResolvedValue([ + nativePurchase('community', { + store: 'unknown', + storeId: 'community-fixture', + transactionId: 'opaque-txn', + }), + ]); + expect(await getAvailablePurchases()).toEqual([ + expect.objectContaining({ + store: 'unknown', + storeId: 'community-fixture', + transactionId: 'opaque-txn', + }), + ]); + }); + it('getAvailablePurchases rejects mixed malformed results atomically', async () => { Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.getAvailableItems as jest.Mock) = jest @@ -1333,6 +1357,7 @@ describe('Public API (index.ts)', () => { .mockResolvedValue([ nativePurchase('foreign', { store: 'google', + storeId: 'play', transactionId: 'foreign', }), ]); @@ -1342,6 +1367,66 @@ describe('Public API (index.ts)', () => { }); }); + it.each([ + ['ios', 'apple'], + ['android', 'google'], + ['android', 'horizon'], + ['android', 'amazon'], + ])('rejects contradictory %s %s identity', async (platform, store) => { + Object.assign(Platform, {OS: platform}); + jest + .mocked(ExpoIapModule.getAvailableItems) + .mockResolvedValue([nativePurchase({store, storeId: 'other'})]); + await expect(getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + + it('getAvailablePurchases preserves community identity on Android', async () => { + Object.assign(Platform, {OS: 'android'}); + jest.mocked(ExpoIapModule.getAvailableItems).mockResolvedValue([ + nativePurchase('community', { + store: 'unknown', + storeId: 'community-fixture', + }), + ]); + await expect(getAvailablePurchases()).resolves.toEqual([ + expect.objectContaining({ + store: 'unknown', + storeId: 'community-fixture', + }), + ]); + await expect(restorePurchases()).resolves.toBeUndefined(); + }); + + it.each([ + undefined, + '', + 'play', + 'apple', + 'google', + 'horizon', + 'amazon', + 'auto', + 'none', + 'unknown', + 'bad id', + 'store\n', + ])( + 'getAvailablePurchases rejects invalid community identity %s', + async (storeId) => { + Object.assign(Platform, {OS: 'android'}); + jest + .mocked(ExpoIapModule.getAvailableItems) + .mockResolvedValue([ + nativePurchase('community', {store: 'unknown', storeId}), + ]); + await expect(getAvailablePurchases()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); + }, + ); + it('getAvailablePurchases rejects a foreign store on Android', async () => { Object.assign(Platform, {OS: 'android'}); (ExpoIapModule.getAvailableItems as jest.Mock) = jest @@ -1349,6 +1434,7 @@ describe('Public API (index.ts)', () => { .mockResolvedValue([ nativePurchase('foreign', { store: 'apple', + storeId: 'apple', transactionId: 'foreign', }), ]); @@ -1361,7 +1447,9 @@ describe('Public API (index.ts)', () => { it('restorePurchases propagates iOS sync failure without querying', async () => { Object.assign(Platform, {OS: 'ios'}); const syncError = new Error('sync failed'); - jest.spyOn(iosMod, 'syncIOS').mockRejectedValue(syncError); + (ExpoIapModule.restorePurchases as jest.Mock) = jest + .fn() + .mockRejectedValue(syncError); (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); await expect(restorePurchases()).rejects.toBe(syncError); @@ -1370,7 +1458,9 @@ describe('Public API (index.ts)', () => { it('restorePurchases rejects a false iOS sync result', async () => { Object.assign(Platform, {OS: 'ios'}); - jest.spyOn(iosMod, 'syncIOS').mockResolvedValue(false); + (ExpoIapModule.restorePurchases as jest.Mock) = jest + .fn() + .mockResolvedValue(false); (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); await expect(restorePurchases()).rejects.toMatchObject({ @@ -1381,10 +1471,43 @@ describe('Public API (index.ts)', () => { }); describe('finishTransaction', () => { + it('forwards full community purchase identity to Android completion', async () => { + Object.assign(Platform, {OS: 'android'}); + const purchase = nativePurchase('opaque-id', { + store: 'unknown', + storeId: 'community-fixture', + purchaseToken: 'opaque-receipt', + }); + (ExpoIapModule.finishTransaction as jest.Mock) = jest + .fn() + .mockResolvedValue(null); + await finishTransaction({purchase, isConsumable: true}); + expect(ExpoIapModule.finishTransaction).toHaveBeenCalledWith( + purchase, + true, + ); + }); + it('passes a boolean when isConsumable is null', async () => { + Object.assign(Platform, {OS: 'android'}); + const purchase = nativePurchase('opaque-id', { + store: 'unknown', + storeId: 'community-fixture', + purchaseToken: 'opaque-receipt', + }); + (ExpoIapModule.finishTransaction as jest.Mock) = jest + .fn() + .mockResolvedValue(null); + await finishTransaction({purchase, isConsumable: null}); + expect(ExpoIapModule.finishTransaction).toHaveBeenCalledWith( + purchase, + false, + ); + }); it('iOS forwards purchase payload to native finishTransaction', async () => { Object.assign(Platform, {OS: 'ios'}); const basePurchase: PurchaseInput = { store: 'apple', + storeId: 'apple', productId: 'prod.ios', isAutoRenewing: false, purchaseState: 'purchased', @@ -1426,6 +1549,7 @@ describe('Public API (index.ts)', () => { const basePurchase: PurchaseInput = { store: 'google', + storeId: 'play', productId: 'p', isAutoRenewing: false, purchaseState: 'purchased', @@ -1455,6 +1579,7 @@ describe('Public API (index.ts)', () => { const p = finishTransaction({ purchase: { store: 'google', + storeId: 'play', productId: 'p', isAutoRenewing: false, purchaseState: 'purchased', @@ -1478,6 +1603,7 @@ describe('Public API (index.ts)', () => { purchase: { id: 'tid', store: 'unknown', + storeId: 'unknown', productId: 'prod.web', isAutoRenewing: false, purchaseState: 'purchased', @@ -1984,6 +2110,46 @@ describe('Public API (index.ts)', () => { }); describe('verifyPurchaseWithProvider', () => { + it.each([ + ['google', undefined, 'play'], + ['apple', undefined, 'apple'], + ['horizon', undefined, 'horizon'], + ['amazon', undefined, 'amazon'], + ['unknown', 'community-store', 'community-store'], + ])( + 'preserves verification identity for %s', + async (store, storeId, expected) => { + ( + ExpoIapModule.verifyPurchaseWithProvider as jest.Mock + ).mockResolvedValueOnce({ + provider: 'iapkit', + iapkit: {isValid: true, state: 'entitled', store, storeId}, + }); + const result = await verifyPurchaseWithProvider({provider: 'iapkit'}); + expect(result.iapkit?.storeId).toBe(expected); + }, + ); + + it.each([ + ['unknown', undefined], + ['unknown', 'unknown'], + ['google', 'community-store'], + ['apple', 'play'], + ])( + 'rejects contradictory verification identity for %s/%s', + async (store, storeId) => { + ( + ExpoIapModule.verifyPurchaseWithProvider as jest.Mock + ).mockResolvedValueOnce({ + provider: 'iapkit', + iapkit: {isValid: true, state: 'entitled', store, storeId}, + }); + await expect( + verifyPurchaseWithProvider({provider: 'iapkit'}), + ).rejects.toThrow(/store identity/); + }, + ); + beforeEach(() => { jest.clearAllMocks(); }); @@ -1996,6 +2162,7 @@ describe('Public API (index.ts)', () => { isValid: true, state: 'entitled', store: 'apple', + storeId: 'apple', productId: 'premium.monthly', clientPayload: { format: 'toml', @@ -2043,6 +2210,7 @@ describe('Public API (index.ts)', () => { productId: null, state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }, }; (ExpoIapModule.verifyPurchaseWithProvider as jest.Mock) = jest @@ -2073,6 +2241,7 @@ describe('Public API (index.ts)', () => { isValid: true, state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }); expect(result.iapkit?.store).toBe('amazon'); }); diff --git a/libraries/expo-iap/src/__tests__/native-log-redaction.test.js b/libraries/expo-iap/src/__tests__/native-log-redaction.test.js index 1619b078e..12140652f 100644 --- a/libraries/expo-iap/src/__tests__/native-log-redaction.test.js +++ b/libraries/expo-iap/src/__tests__/native-log-redaction.test.js @@ -178,7 +178,10 @@ describe('native log redaction', () => { it('does not log raw IAPKit request bodies in the Apple core package', () => { const appleModule = readFileSync( - resolve(rootDir, '../../packages/apple/Sources/OpenIapModule.swift'), + resolve( + rootDir, + '../../packages/apple/Sources/OpenIapStoreKitModule.swift', + ), 'utf8', ); @@ -230,7 +233,7 @@ describe('native error and listener bridges', () => { 'removeSubscriptionBillingIssueListener(handles.subscriptionBillingIssue)', ); expect(androidModule).toContain( - 'ExpoIapHelper.cleanupListeners(openIap, listenerHandles)', + 'listenerHandles?.let { ExpoIapHelper.cleanupListeners(openIap, it) }', ); }); }); diff --git a/libraries/expo-iap/src/__tests__/standardized-offer-types.test.ts b/libraries/expo-iap/src/__tests__/standardized-offer-types.test.ts index 8afc8cfca..4275c8c79 100644 --- a/libraries/expo-iap/src/__tests__/standardized-offer-types.test.ts +++ b/libraries/expo-iap/src/__tests__/standardized-offer-types.test.ts @@ -618,6 +618,7 @@ describe('Standardized Offer Types', () => { transactionDate: 1700000000000, purchaseToken: 'current_token', store: 'google', + storeId: 'play', quantity: 1, purchaseState: 'purchased', isAutoRenewing: true, @@ -642,6 +643,7 @@ describe('Standardized Offer Types', () => { productId: 'regular_product', transactionDate: 1700000000000, store: 'google', + storeId: 'play', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -658,6 +660,7 @@ describe('Standardized Offer Types', () => { productId: 'premium_yearly', transactionDate: 1700000000000, store: 'google', + storeId: 'play', quantity: 1, purchaseState: 'purchased', isAutoRenewing: true, diff --git a/libraries/expo-iap/src/__tests__/useIAP.test.tsx b/libraries/expo-iap/src/__tests__/useIAP.test.tsx index 5f816cbc5..75c7f1bc8 100644 --- a/libraries/expo-iap/src/__tests__/useIAP.test.tsx +++ b/libraries/expo-iap/src/__tests__/useIAP.test.tsx @@ -81,6 +81,9 @@ describe('useIAP hook', () => { .fn() .mockResolvedValue(true); (ExpoIapModule.syncIOS as jest.Mock) = jest.fn().mockResolvedValue(true); + (ExpoIapModule.restorePurchases as jest.Mock) = jest + .fn() + .mockResolvedValue(true); (ExpoIapModule.addListener as jest.Mock) = jest.fn().mockReturnValue({ remove: jest.fn(), }); @@ -349,6 +352,9 @@ describe('useIAP hook', () => { }); it('calls onError when restorePurchases fails', async () => { + (ExpoIapModule.restorePurchases as jest.Mock) = jest + .fn() + .mockResolvedValue(true); const mockError = new Error('Restore failed'); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() @@ -385,35 +391,39 @@ describe('useIAP hook', () => { expect(onError).toHaveBeenCalledWith(mockError); }); - it('stops restore after an iOS sync rejection', async () => { - const mockError = new Error('App Store sync failed'); - (ExpoIapModule.syncIOS as jest.Mock) = jest - .fn() - .mockRejectedValue(mockError); - (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); - - const onError = jest.fn(); - let hookResult: ReturnType | null = null; - await ReactTestRenderer.act(async () => { - ReactTestRenderer.create( - { - hookResult = hook; - }} - />, - ); - await flushPromises(); - }); + it.each(['ios', 'android'])( + 'stops restore after a %s provider rejection', + async (platform) => { + Object.assign(Platform, {OS: platform}); + const mockError = new Error('App Store sync failed'); + (ExpoIapModule.restorePurchases as jest.Mock) = jest + .fn() + .mockRejectedValue(mockError); + (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); + + const onError = jest.fn(); + let hookResult: ReturnType | null = null; + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create( + { + hookResult = hook; + }} + />, + ); + await flushPromises(); + }); - await ReactTestRenderer.act(async () => { - await expect(hookResult!.restorePurchases()).rejects.toBe(mockError); - }); + await ReactTestRenderer.act(async () => { + await expect(hookResult!.restorePurchases()).rejects.toBe(mockError); + }); - expect(ExpoIapModule.getAvailableItems).not.toHaveBeenCalled(); - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(mockError); - }); + expect(ExpoIapModule.getAvailableItems).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(mockError); + }, + ); it('does not call onError when fetchProducts succeeds', async () => { (ExpoIapModule.fetchProducts as jest.Mock) = jest @@ -712,6 +722,7 @@ describe('useIAP hook', () => { purchaseState: 'purchased', quantity: 1, store: 'apple', + storeId: 'apple', transactionDate: 1720000000000, }, }); diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index a835288df..c92b8249f 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -422,6 +422,7 @@ describe('Amazon Vega Expo adapter', () => { productId: 'coins_100', purchaseToken: 'receipt-1', store: 'amazon', + storeId: 'amazon', }), ]); expect(listener).toHaveBeenCalledWith( @@ -574,6 +575,7 @@ describe('Amazon Vega Expo adapter', () => { productId: 'coins_100', purchaseToken: 'recovered-receipt', store: 'amazon', + storeId: 'amazon', }), ]); expect(listener).toHaveBeenCalledWith( @@ -623,6 +625,7 @@ describe('Amazon Vega Expo adapter', () => { productId: 'coins_100', purchaseToken: 'recovered-receipt', store: 'amazon', + storeId: 'amazon', }), ]); expect(listener).toHaveBeenCalledWith( @@ -1385,6 +1388,7 @@ describe('Amazon Vega Expo adapter', () => { isValid: true, state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', }), ) as unknown as jest.MockedFunction; globalThis.fetch = fetchMock; @@ -1411,6 +1415,7 @@ describe('Amazon Vega Expo adapter', () => { isValid: true, state: 'entitled', store: 'amazon', + storeId: 'amazon', }, }); @@ -1429,6 +1434,7 @@ describe('Amazon Vega Expo adapter', () => { ); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ store: 'amazon', + storeId: 'amazon', userId: 'amazon-user', receiptId: 'receipt-vega-1', expectedProductId: 'amazon.premium.monthly', @@ -1447,6 +1453,7 @@ describe('Amazon Vega Expo adapter', () => { isValid: true, state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', productId: 'premium.monthly', clientPayload: { format: 'toml', @@ -1476,9 +1483,11 @@ describe('Amazon Vega Expo adapter', () => { productId: 'premium.monthly', state: 'entitled', store: 'amazon', + storeId: 'amazon', }); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ store: 'amazon', + storeId: 'amazon', userId: 'amazon-user', receiptId: 'receipt-vega-1', }); @@ -1514,6 +1523,7 @@ describe('Amazon Vega Expo adapter', () => { isValid: true, state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', }), ) as unknown as jest.MockedFunction; globalThis.fetch = fetchMock; @@ -1838,6 +1848,7 @@ describe('Amazon Vega Expo adapter', () => { { state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', }, {status: 200}, ), @@ -1884,6 +1895,7 @@ describe('Amazon Vega Expo adapter', () => { isValid: true, state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', }), ) as unknown as jest.MockedFunction; globalThis.fetch = fetchMock; @@ -1919,6 +1931,7 @@ describe('Amazon Vega Expo adapter', () => { isValid: true, state: 'ENTITLED', store: 'apple', + storeId: 'apple', }, {status: 200}, ), diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index c4eba1c54..9e28d69e0 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -16,7 +16,7 @@ import { } from './modules/android'; import {ExpoIapConsole} from './utils/debug'; import {showFirstPurchaseNotice} from './utils/firstPurchaseNotice'; -import {restorePurchasesIOSNative} from './utils/restorePurchases'; +import {restorePurchasesNative} from './utils/restorePurchases'; import { decodeAndroidPurchases, decodeApplePurchases, @@ -45,7 +45,7 @@ import type { RequestSubscriptionIosProps, UserChoiceBillingDetails, } from './types'; -import {ErrorCode} from './types'; +import {ErrorCode, resolveStoreId} from './types'; import { createPurchaseError, createPurchaseErrorFromNativeException, @@ -591,8 +591,8 @@ const invokeNativeWithPurchaseError = async ( typeof nativeError?.message === 'string' ? nativeError.message : typeof error === 'string' - ? error - : ''; + ? error + : ''; const hasCanonicalFields = nativeMessage.includes(OPENIAP_ERROR_ENVELOPE_PREFIX) || nativeError?.code !== undefined || @@ -1189,10 +1189,15 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( */ export const finishTransaction: MutationField<'finishTransaction'> = async ({ purchase, - isConsumable = false, + isConsumable, }) => { - if (Platform.OS === 'ios') { - await ExpoIapModule.finishTransaction(purchase, isConsumable); + // The schema allows explicit null; the native boolean cannot take it. + const consumable = isConsumable ?? false; + if ( + Platform.OS === 'ios' || + (isAndroidStoreRuntime() && purchase.store === 'unknown') + ) { + await ExpoIapModule.finishTransaction(purchase, consumable); } else if (isAndroidStoreRuntime()) { const token = purchase.purchaseToken ?? undefined; @@ -1205,7 +1210,7 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({ }); } - if (isConsumable) { + if (consumable) { await ExpoIapModule.consumePurchaseAndroid(token); } else { await ExpoIapModule.acknowledgePurchaseAndroid(token); @@ -1222,14 +1227,12 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({ * `getAvailablePurchases` or from hook state. * * - iOS: sync (or Onside restore when OnsideKit is active), then fetch available purchases. - * - Android: fetch available purchases; the query itself restores them. + * - Android: run the provider's restore, then fetch available purchases. * * @see {@link https://openiap.dev/docs/apis/restore-purchases} */ export const restorePurchases: MutationField<'restorePurchases'> = async () => { - if (Platform.OS === 'ios') { - await restorePurchasesIOSNative(); - } + await restorePurchasesNative(); await getAvailablePurchases({ alsoPublishToEventListenerIOS: false, @@ -1383,9 +1386,8 @@ export const verifyPurchaseWithProvider: MutationField< } } - const result = await ExpoIapModule.verifyPurchaseWithProvider( - resolvedOptions, - ); + const result = + await ExpoIapModule.verifyPurchaseWithProvider(resolvedOptions); if (result.iapkit == null) { return result; } @@ -1395,6 +1397,7 @@ export const verifyPurchaseWithProvider: MutationField< ...result, iapkit: { ...iapkit, + storeId: resolveStoreId(iapkit.storeId, iapkit.store), ...(clientPayload == null ? {} : {clientPayload}), ...(productId == null ? {} : {productId}), }, diff --git a/libraries/expo-iap/src/modules/__tests__/ios.test.ts b/libraries/expo-iap/src/modules/__tests__/ios.test.ts index d8fa06e13..abdabdd70 100644 --- a/libraries/expo-iap/src/modules/__tests__/ios.test.ts +++ b/libraries/expo-iap/src/modules/__tests__/ios.test.ts @@ -20,6 +20,8 @@ jest.mock('../../ExpoIapModule', () => ({ getPendingTransactionsIOS: jest.fn(), getAllTransactionsIOS: jest.fn(), clearTransactionIOS: jest.fn(), + HAS_EXPLICIT_STORE_PROVIDER: false, + deepLinkToSubscriptionsIOS: jest.fn(), canPresentExternalPurchaseNoticeIOS: jest.fn(), presentExternalPurchaseNoticeSheetIOS: jest.fn(), presentExternalPurchaseLinkIOS: jest.fn(), @@ -82,6 +84,7 @@ const validPurchase = (id: string) => ({ productId: 'premium', transactionDate: 1720000000000, store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -271,6 +274,19 @@ describe('iOS Module Functions', () => { }); describe('Deep Link Functions', () => { + it('opens the explicit provider management flow', async () => { + Object.assign(ExpoIapModule, {HAS_EXPLICIT_STORE_PROVIDER: true}); + (ExpoIapModule.deepLinkToSubscriptionsIOS as jest.Mock).mockResolvedValue( + true, + ); + try { + await deepLinkToSubscriptionsIOS(); + expect(ExpoIapModule.deepLinkToSubscriptionsIOS).toHaveBeenCalled(); + expect(Linking.openURL).not.toHaveBeenCalled(); + } finally { + Object.assign(ExpoIapModule, {HAS_EXPLICIT_STORE_PROVIDER: false}); + } + }); it('should open subscriptions management URL', async () => { await deepLinkToSubscriptionsIOS(); @@ -499,6 +515,7 @@ describe('iOS Module Functions', () => { id: 'redeemed-transaction', productId: 'premium', store: 'apple', + storeId: 'apple', }; ( ExpoIapModule.presentCodeRedemptionSheetIOS as jest.Mock @@ -753,9 +770,8 @@ describe('iOS Module Functions', () => { ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS as jest.Mock ).mockResolvedValue(mockResult); - const result = await getExternalPurchaseCustomLinkTokenIOS( - 'acquisition', - ); + const result = + await getExternalPurchaseCustomLinkTokenIOS('acquisition'); expect( ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS, @@ -785,9 +801,8 @@ describe('iOS Module Functions', () => { ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS as jest.Mock ).mockResolvedValue(mockResult); - const result = await getExternalPurchaseCustomLinkTokenIOS( - 'acquisition', - ); + const result = + await getExternalPurchaseCustomLinkTokenIOS('acquisition'); expect(result.error).toBe('App not eligible'); expect(result.token).toBeUndefined(); diff --git a/libraries/expo-iap/src/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts index 1add5bcc0..0009ca8f5 100644 --- a/libraries/expo-iap/src/modules/ios.ts +++ b/libraries/expo-iap/src/modules/ios.ts @@ -376,7 +376,9 @@ export const clearTransactionIOS: MutationField< * @platform iOS */ export const deepLinkToSubscriptionsIOS = (): Promise => - Linking.openURL('https://apps.apple.com/account/subscriptions'); + ExpoIapModule.HAS_EXPLICIT_STORE_PROVIDER + ? ExpoIapModule.deepLinkToSubscriptionsIOS().then(() => undefined) + : Linking.openURL('https://apps.apple.com/account/subscriptions'); /** * Check if the device can present an external purchase notice sheet (iOS 17.4+). @@ -465,9 +467,8 @@ export const getExternalPurchaseCustomLinkTokenIOS: QueryField< "getExternalPurchaseCustomLinkTokenIOS requires a tokenType ('acquisition' or 'services')", ); } - const result = await ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS( - tokenType, - ); + const result = + await ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS(tokenType); return result as ExternalPurchaseCustomLinkTokenResultIOS; }; @@ -491,9 +492,8 @@ export const showExternalPurchaseCustomLinkNoticeIOS: MutationField< "showExternalPurchaseCustomLinkNoticeIOS requires a noticeType ('browser')", ); } - const result = await ExpoIapModule.showExternalPurchaseCustomLinkNoticeIOS( - noticeType, - ); + const result = + await ExpoIapModule.showExternalPurchaseCustomLinkNoticeIOS(noticeType); return result as ExternalPurchaseCustomLinkNoticeResultIOS; }; diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 54449c7f0..45c72105b 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -3,7 +3,25 @@ // Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ +export const StoreIds = { + Apple: 'apple', + Play: 'play', + Horizon: 'horizon', + Amazon: 'amazon', +} as const; + +export function resolveStoreId(value: unknown, store: IapStore): string { + const officialIds: Partial> = {'apple': 'apple', 'google': 'play', 'horizon': 'horizon', 'amazon': 'amazon'}; + const official = officialIds[store]; + const id = value ?? official; + if (typeof id !== 'string' || (official != null ? id !== official : store !== 'unknown' || id.match(/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/)?.[0] !== id || ['auto', 'none', 'unknown', 'apple', 'play', 'google', 'horizon', 'amazon'].includes(id))) { + throw new Error('Invalid store identity'); + } + return id; +} + export interface ActiveSubscription { + /** Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. */ autoRenewingAndroid?: (boolean | null); basePlanIdAndroid?: (string | null); /** @@ -586,6 +604,7 @@ export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product export type IapPlatform = 'ios' | 'android'; +/** Frozen legacy store discriminator. Use storeId for extensible store identity. */ export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon' | 'amazon'; /** Serialization format of a public IAPKit product client payload. */ @@ -856,6 +875,7 @@ export interface Mutation { presentExternalPurchaseNoticeSheetIOS: Promise; /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ requestPurchase?: Promise<(Purchase | Purchase[] | null)>; @@ -1203,6 +1223,7 @@ export interface PromotionalOfferJwsInputIOS { export type Purchase = PurchaseAndroid | PurchaseIOS; export interface PurchaseAndroid extends PurchaseCommon { + /** Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. */ autoRenewingAndroid?: (boolean | null); currentPlanId?: (string | null); dataAndroid?: (string | null); @@ -1210,6 +1231,7 @@ export interface PurchaseAndroid extends PurchaseCommon { id: string; ids?: (string[] | null); isAcknowledgedAndroid?: (boolean | null); + /** Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. */ isAutoRenewing: boolean; /** * Whether the subscription is suspended (Android) @@ -1236,6 +1258,8 @@ export interface PurchaseAndroid extends PurchaseCommon { signatureAndroid?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId?: (string | null); @@ -1262,6 +1286,7 @@ export interface PurchaseCommon { currentPlanId?: (string | null); id: string; ids?: (string[] | null); + /** Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. */ isAutoRenewing: boolean; productId: string; purchaseState: PurchaseState; @@ -1270,6 +1295,8 @@ export interface PurchaseCommon { quantity: number; /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; } @@ -1318,6 +1345,7 @@ export interface PurchaseIOS extends PurchaseCommon { expirationDateIOS?: (number | null); id: string; ids?: (string[] | null); + /** Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. */ isAutoRenewing: boolean; isUpgradedIOS?: (boolean | null); offerIOS?: (PurchaseOfferIOS | null); @@ -1348,6 +1376,8 @@ export interface PurchaseIOS extends PurchaseCommon { revocationTypeIOS?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; storefrontCountryCodeIOS?: (string | null); subscriptionGroupIdIOS?: (string | null); /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -1692,10 +1722,8 @@ export type RequestPurchaseProps = * Platform-specific purchase request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ export interface RequestPurchasePropsByPlatforms { /** Apple-specific purchase parameters */ @@ -1792,10 +1820,8 @@ export interface RequestSubscriptionIosProps { * Platform-specific subscription request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ export interface RequestSubscriptionPropsByPlatforms { /** Apple-specific subscription parameters */ @@ -1905,6 +1931,21 @@ export interface RequestVerifyPurchaseWithIapkitResult { /** The current state of the purchase. */ state: IapkitPurchaseState; store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; +} + +/** + * Store-provider contract shared by the Apple and Android native bindings. + * coreVersion names the native contract build; clientProtocolVersion names the + * Client Protocol build. Capabilities use the conformance provider profile ids. + */ +export interface StoreProviderDescriptor { + capabilities: string[]; + clientProtocolVersion: string; + coreVersion: string; + platform: IapPlatform; + storeId: string; } /** diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts index 1b9ef9ced..1f351184a 100644 --- a/libraries/expo-iap/src/useIAP.ts +++ b/libraries/expo-iap/src/useIAP.ts @@ -27,7 +27,7 @@ import { } from './index'; import {ExpoIapConsole} from './utils/debug'; import {getPromotedProductIOS} from './modules/ios'; -import {restorePurchasesIOSNative} from './utils/restorePurchases'; +import {restorePurchasesNative} from './utils/restorePurchases'; import { getBillingChoiceInfoAndroid, isBillingProgramAvailableAndroid, @@ -262,16 +262,8 @@ export function useIAP(options?: UseIAPOptions): UseIap { const toPurchaseInput = useCallback( (purchase: Purchase): PurchaseInput => ({ - id: purchase.id, + ...purchase, ids: purchase.ids ?? undefined, - isAutoRenewing: purchase.isAutoRenewing, - productId: purchase.productId, - purchaseState: purchase.purchaseState, - purchaseToken: purchase.purchaseToken ?? null, - quantity: purchase.quantity, - store: purchase.store, - transactionDate: purchase.transactionDate, - transactionId: purchase.transactionId, }), [], ); @@ -555,8 +547,8 @@ export function useIAP(options?: UseIAPOptions): UseIap { const purchases = Array.isArray(purchaseResult) ? purchaseResult : purchaseResult - ? [purchaseResult] - : []; + ? [purchaseResult] + : []; for (const purchase of purchases ?? []) { if (!markPurchaseDelivered(purchase)) { @@ -584,9 +576,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { const restorePurchasesInternal = useCallback( async (options?: PurchaseOptions): Promise => { try { - if (Platform.OS === 'ios') { - await restorePurchasesIOSNative(); - } + await restorePurchasesNative(); const purchases = await getAvailablePurchases({ alsoPublishToEventListenerIOS: diff --git a/libraries/expo-iap/src/utils/__tests__/firstPurchaseNotice.test.ts b/libraries/expo-iap/src/utils/__tests__/firstPurchaseNotice.test.ts index 0e8c6dd23..bc3a557ea 100644 --- a/libraries/expo-iap/src/utils/__tests__/firstPurchaseNotice.test.ts +++ b/libraries/expo-iap/src/utils/__tests__/firstPurchaseNotice.test.ts @@ -47,6 +47,7 @@ const purchase = (overrides: Partial = {}): PurchaseInput => ({ purchaseToken: 'token', quantity: 1, store: 'apple', + storeId: 'apple', transactionDate: 1720000000000, ...overrides, }); diff --git a/libraries/expo-iap/src/utils/availablePurchases.ts b/libraries/expo-iap/src/utils/availablePurchases.ts index e68d946cd..d05d5c52e 100644 --- a/libraries/expo-iap/src/utils/availablePurchases.ts +++ b/libraries/expo-iap/src/utils/availablePurchases.ts @@ -1,4 +1,9 @@ -import {ErrorCode, type Purchase, type PurchaseIOS} from '../types'; +import { + resolveStoreId, + ErrorCode, + type Purchase, + type PurchaseIOS, +} from '../types'; import {createPurchaseError} from './errorMapping'; const ANDROID_STORES = new Set(['google', 'amazon', 'horizon']); @@ -73,18 +78,34 @@ export const decodeAvailablePurchases = (value: unknown): Purchase[] => { } }); - return value as Purchase[]; + return value.map((item, index) => { + const purchase = item as Purchase; + try { + return { + ...purchase, + storeId: resolveStoreId(purchase.storeId, purchase.store), + }; + } catch { + throw malformedPurchaseError( + `Native bridge returned an invalid store identity at index ${index}`, + ); + } + }); }; -/** Decode an authoritative StoreKit list without filtering foreign entries. */ +/** Decode an authoritative Apple-platform list without filtering foreign entries. */ export const decodeApplePurchases = (value: unknown): PurchaseIOS[] => { const decoded = decodeAvailablePurchases(value); const invalidIndex = decoded.findIndex( - (purchase) => purchase.store !== 'apple', + (purchase) => + (purchase.store !== 'apple' && purchase.store !== 'unknown') || + !('transactionId' in purchase) || + typeof purchase.transactionId !== 'string' || + !purchase.transactionId, ); if (invalidIndex !== -1) { throw malformedPurchaseError( - `Native StoreKit bridge returned a non-Apple purchase at index ${invalidIndex}`, + `Native Apple bridge returned an invalid iOS purchase at index ${invalidIndex}`, ); } return decoded as PurchaseIOS[]; @@ -94,7 +115,8 @@ export const decodeApplePurchases = (value: unknown): PurchaseIOS[] => { export const decodeAndroidPurchases = (value: unknown): Purchase[] => { const decoded = decodeAvailablePurchases(value); const invalidIndex = decoded.findIndex( - (purchase) => !ANDROID_STORES.has(purchase.store), + (purchase) => + !ANDROID_STORES.has(purchase.store) && purchase.store !== 'unknown', ); if (invalidIndex !== -1) { throw malformedPurchaseError( diff --git a/libraries/expo-iap/src/utils/restorePurchases.ts b/libraries/expo-iap/src/utils/restorePurchases.ts index 10d7658d6..176c8a9bf 100644 --- a/libraries/expo-iap/src/utils/restorePurchases.ts +++ b/libraries/expo-iap/src/utils/restorePurchases.ts @@ -1,30 +1,28 @@ import ExpoIapModule from '../ExpoIapModule'; +import {Platform} from 'react-native'; +import {isVegaOS} from '../vega'; import {syncIOS} from '../modules/ios'; import {ErrorCode} from '../types'; import {createPurchaseError} from './errorMapping'; -/** - * Run the native iOS restore/sync phase without querying purchases. - * - * Both native implementations promise an authoritative boolean. A rejection - * or false result must reach the caller so restore cannot report success from - * a subsequent empty purchase query. - */ -export const restorePurchasesIOSNative = async (): Promise => { - const usingOnside = - ExpoIapModule.USING_ONSIDE_SDK && +/** Restore through the selected provider before querying ownership. */ +export const restorePurchasesNative = async (): Promise => { + if (isVegaOS()) return; + const usingNativeRestore = typeof ExpoIapModule.restorePurchases === 'function'; - const restored = usingOnside + const restored = usingNativeRestore ? await ExpoIapModule.restorePurchases?.() - : await syncIOS(); + : Platform.OS === 'ios' + ? await syncIOS() + : true; if (restored !== true) { throw createPurchaseError({ code: ErrorCode.SyncError, - message: usingOnside - ? 'Onside purchase restore did not complete' + message: usingNativeRestore + ? 'Store purchase restore did not complete' : 'App Store purchase sync did not complete', - platform: 'ios', + platform: Platform.OS === 'ios' ? 'ios' : 'android', }); } }; diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index 697775e29..0302709b1 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -753,6 +753,7 @@ function mapReceipt( type === 'subs' ? (nonBlankString(receipt.termSku) ?? productId) : null, ids: productId ? [productId] : [], store: 'amazon', + storeId: 'amazon', quantity: 1, purchaseState: isActive ? 'purchased' : 'unknown', isAutoRenewing: type === 'subs' && isActive, @@ -1232,6 +1233,7 @@ export function createExpoIapVegaModule( ...(productId == null ? {} : {productId}), state: normalizeIapkitState(json.state), store: 'amazon', + storeId: 'amazon', }; } @@ -1294,6 +1296,7 @@ export function createExpoIapVegaModule( }, body: JSON.stringify({ store: 'amazon', + storeId: 'amazon', userId, receiptId, ...(amazon.expectedProductId == null diff --git a/libraries/flutter_inapp_purchase/android/build.gradle b/libraries/flutter_inapp_purchase/android/build.gradle index 7dfa53aad..41f75857c 100644 --- a/libraries/flutter_inapp_purchase/android/build.gradle +++ b/libraries/flutter_inapp_purchase/android/build.gradle @@ -141,7 +141,8 @@ def openIapTargetSdkVersion = openIapResolveAndroidSdkVersion('targetSdkVersion' // Store selection: explicit property > task flavor > connected debug device > play. // openiapStore=none (legacy openiapPlatform=none) builds without any Android IAP SDK. apply from: project.file('openiap-store.gradle') -def openiapPlatform = openIapResolveStore('flutter_inapp_purchase', [allowNone: true]).store +def openiapSelection = openIapResolveStore('flutter_inapp_purchase', [allowNone: true]) +def openiapPlatform = openiapSelection.store def androidIapDisabled = openiapPlatform == 'none' android { @@ -156,7 +157,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" if (!androidIapDisabled) { - missingDimensionStrategy "platform", openiapPlatform + missingDimensionStrategy "platform", openiapSelection.platform } buildConfigField "String", "OPENIAP_STORE", "\"${openiapPlatform}\"" } @@ -169,6 +170,9 @@ android { androidIapDisabled ? 'src/noneTest/kotlin' : 'src/storeTest/kotlin', ]) } + testOptions { + unitTests.includeAndroidResources = true + } lint { disable 'InvalidPackage' } @@ -190,16 +194,7 @@ kotlin { dependencies { if (!androidIapDisabled) { - def localGoogleProject = findProject(':openiap') - if (localGoogleProject != null) { - implementation project(':openiap') - } else if (openiapPlatform == 'amazon') { - implementation "io.github.hyochan.openiap:openiap-google-amazon:${openiapGoogleVersion}" - } else if (openiapPlatform == 'horizon') { - implementation "io.github.hyochan.openiap:openiap-google-horizon:${openiapGoogleVersion}" - } else { - implementation "io.github.hyochan.openiap:openiap-google:${openiapGoogleVersion}" - } + openIapAddStoreDependencies('implementation', openiapGoogleVersion, ':openiap') implementation "androidx.annotation:annotation:${readRequiredAndroidGradleProperty(projectDir, 'openIapAndroidAnnotationVersion')}" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${readRequiredAndroidGradleProperty(projectDir, 'openIapKotlinxCoroutinesVersion')}" @@ -207,5 +202,9 @@ dependencies { testImplementation "junit:junit:${readRequiredAndroidGradleProperty(projectDir, 'openIapJunitVersion')}" + if (!androidIapDisabled) { + testImplementation "org.robolectric:robolectric:4.16.1" + } + // Store SDK versions stay centralized in packages/google. } diff --git a/libraries/flutter_inapp_purchase/android/settings.gradle b/libraries/flutter_inapp_purchase/android/settings.gradle index 0245c1d6d..af3939ebf 100644 --- a/libraries/flutter_inapp_purchase/android/settings.gradle +++ b/libraries/flutter_inapp_purchase/android/settings.gradle @@ -19,4 +19,8 @@ def localOpenIapProject = new File(settingsDir, '../../../packages/google/openia if (localOpenIapProject.exists()) { include ':openiap' project(':openiap').projectDir = localOpenIapProject + include ':openiap-core' + project(':openiap-core').projectDir = new File(settingsDir, '../../../packages/google/core') + include ':openiap-conformance' + project(':openiap-conformance').projectDir = new File(settingsDir, '../../../packages/conformance/android') } diff --git a/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index cc791c430..25879c04a 100644 --- a/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -25,7 +25,8 @@ import dev.hyo.openiap.InitConnectionConfig import dev.hyo.openiap.LaunchExternalLinkParamsAndroid import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapLog -import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProvider +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.ProductRequest import dev.hyo.openiap.Purchase @@ -36,6 +37,7 @@ import dev.hyo.openiap.helpers.OpenIapFirstPurchaseNotice import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.utils.redeemOfferCode import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -50,12 +52,7 @@ import org.json.JSONArray import org.json.JSONObject import java.util.Locale -/** - * AndroidInappPurchasePlugin (OpenIAP-backed) - * - * Implements the existing MethodChannel API using openiap-google (OpenIapModule), - * and adds parity endpoints used by Expo modules for Android. - */ +/** Provider-backed Flutter MethodChannel implementation. */ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, ActivityLifecycleCallbacks { private val job = Job() private val scope = CoroutineScope(Dispatchers.Main + job) @@ -69,7 +66,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act private var listenersAttached = false private val connectionMutex = Mutex() // OpenIAP module instance - private var openIap: OpenIapModule? = null + private var openIap: OpenIapProtocol? = null private fun parseQueryType(raw: String?): ProductQueryType { return when (val normalized = raw?.trim()?.lowercase(Locale.ROOT)) { @@ -210,9 +207,6 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act fun setContext(context: Context?) { this.context = context - if (context != null && openIap == null) { - openIap = OpenIapModule(context) - } } fun setActivity(activity: Activity?) { @@ -289,15 +283,11 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val packageName = call.argument("packageName") scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() iap.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = sku, packageNameAndroid = packageName)) safe.success(true) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } return @@ -305,15 +295,11 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act "openPlayStoreSubscriptions" -> { scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() iap.deepLinkToSubscriptions(DeepLinkOptions()) safe.success(true) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } return @@ -321,19 +307,15 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act "openRedeemOfferCodeAndroid" -> { scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch + val iap = connectionMutex.withLock { + attachListenersIfNeeded() + requireOpenIap() } - activity?.let(iap::setActivity) - val redeem = iap.mutationHandlers.openRedeemOfferCodeAndroid - ?: throw OpenIapError.FeatureNotSupported() - safe.success(redeem()) + safe.success(redeemOfferCode(iap, activity)) } catch (e: OpenIapError) { safe.error(e.code, e.message, serializeOpenIapError(e)) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } return @@ -366,19 +348,13 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act attachListenersIfNeeded() openIap?.setActivity(activity) - // ALWAYS end connection first to reset configuration - // This ensures we start fresh regardless of current state + // Restart the connection to apply billing configuration. try { OpenIapLog.debug("Ending connection before reinitializing (current ready state: $connectionReady)", TAG) openIap?.endConnection() connectionReady = false - // WORKAROUND: OpenIAP's endConnection() is synchronous but may trigger - // async cleanup in the background (e.g., disconnecting from Play Store). - // A small delay reduces the risk of race conditions where initConnection() - // is called before cleanup completes. This is not ideal but necessary - // until OpenIAP provides an async endConnection() or callback mechanism. - // Increase this delay if experiencing connection issues. + // Let Play finish asynchronous disconnection before reconnecting. kotlinx.coroutines.delay(300) } catch (e: Exception) { OpenIapLog.warn("Error ending connection: ${e.message}", TAG) @@ -394,11 +370,15 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act if (ok) { safe.success("Billing client ready") } else { - safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.message, "responseCode: -1") + val initError = initConnectionError() + safe.error(initError.code, initError.message, "responseCode: -1") } + } catch (e: OpenIapError) { + safe.error(e.code, e.message, serializeOpenIapError(e)) } catch (e: Exception) { OpenIapLog.error("Error during initConnection: ${e.message}", e) - safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.message, e.message) + val initError = initConnectionError() + safe.error(initError.code, initError.message, e.message) } } } @@ -409,13 +389,13 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act connectionMutex.withLock { try { OpenIapLog.debug("endConnection called", TAG) - openIap?.endConnection() - connectionReady = false - OpenIapLog.debug("Connection ended successfully", TAG) - safe.success("Billing client has ended.") + val disconnected = openIap?.endConnection() ?: true + if (disconnected) connectionReady = false + OpenIapLog.debug("endConnection result: $disconnected", TAG) + safe.success(disconnected) } catch (e: Exception) { OpenIapLog.error("Error ending connection: ${e.message}", e) - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -443,18 +423,14 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act scope.launch { withBillingReady(safe, autoInit = true) { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@withBillingReady - } + val iap = requireOpenIap() val result = iap.fetchProducts(ProductRequest(skus, queryType)) val arr = fetchResultToJsonArray(result, queryType == ProductQueryType.All) safe.success(arr.toString()) } catch (e: OpenIapError) { safe.error(e.code, e.message, serializeOpenIapError(e)) } catch (e: Exception) { - safe.error(OpenIapError.QueryProduct.CODE, OpenIapError.QueryProduct.MESSAGE, e.message) + replyBillingError(safe, e, OpenIapError.QueryProduct) } } } @@ -468,11 +444,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act scope.launch { withBillingReady(safe, autoInit = true) { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@withBillingReady - } + val iap = requireOpenIap() val options = if (includeSuspended) { dev.hyo.openiap.PurchaseOptions(includeSuspendedAndroid = true) } else { @@ -482,7 +454,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val arr = purchasesToJsonArray(purchases) safe.success(arr.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -497,11 +469,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act scope.launch { withBillingReady(safe) { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@withBillingReady - } + val iap = requireOpenIap() val subscriptions = iap.getActiveSubscriptions(subscriptionIds) val arr = JSONArray() subscriptions.forEach { subscription -> @@ -509,7 +477,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(arr.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -612,18 +580,17 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act ) return@launch } + } catch (e: OpenIapError) { + safe.error(e.code, e.message, serializeOpenIapError(e)) + return@launch } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) return@launch } } try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() val offers = (params["subscriptionOffers"] as? List<*>)?.map { entry -> val map = entry as Map<*, *> @@ -663,29 +630,22 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act "getStorefront" -> { scope.launch { try { - val iap = openIap ?: run { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() val code = iap.getStorefront() safe.success(code) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } "getStorefrontAndroid" -> { scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() val code = iap.getStorefront() safe.success(code) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -695,15 +655,44 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val pkg = params?.get("packageNameAndroid") as? String scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() iap.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = sku, packageNameAndroid = pkg)) safe.success(null) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) + } + } + } + "hasActiveSubscriptions" -> { + val ids = call.argument>("subscriptionIds") + scope.launch { + try { + safe.success(requireOpenIap().hasActiveSubscriptions(ids)) + } catch (e: Exception) { + replyBillingError(safe, e) + } + } + } + "restorePurchases" -> { + scope.launch { + try { + requireOpenIap().restorePurchases() + safe.success(true) + } catch (e: Exception) { + replyBillingError(safe, e) + } + } + } + "finishTransaction" -> { + val purchase = call.argument>("purchase") + val isConsumable = call.argument("isConsumable") ?: false + scope.launch { + try { + val payload = purchase ?: throw OpenIapError.DeveloperError("Missing purchase") + requireOpenIap().finishTransaction(dev.hyo.openiap.PurchaseAndroid.fromJson(payload), isConsumable) + safe.success(null) + } catch (e: Exception) { + replyBillingError(safe, e) } } } @@ -717,16 +706,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() iap.acknowledgePurchaseAndroid(purchaseToken) val resp = JSONObject().apply { put("responseCode", 0) } safe.success(resp.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -740,11 +725,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() iap.consumePurchaseAndroid(purchaseToken) val resp = JSONObject().apply { put("responseCode", 0) @@ -752,7 +733,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(resp.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -762,11 +743,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val programStr = call.argument("program") scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() val program = BillingProgramAndroid.fromJson(programStr ?: "unspecified") val result = iap.isBillingProgramAvailable(program) val response = JSONObject().apply { @@ -777,7 +754,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(response.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -787,11 +764,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act 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 iap = requireOpenIap() val params = GetBillingChoiceInfoParamsAndroid( billingProgram = BillingProgramAndroid.fromJson(programStr ?: "billing-choice"), playBillingChoiceImageLayout = BillingChoiceImageLayoutAndroid.fromJson(imageLayoutStr ?: "rectangular-four-by-one"), @@ -804,7 +777,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(response.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -813,11 +786,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val developerBillingTypeStr = call.argument("developerBillingType") scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() val program = BillingProgramAndroid.fromJson(programStr ?: "unspecified") val developerBillingType = developerBillingTypeStr?.let { DeveloperBillingTypeAndroid.fromJson(it) @@ -829,7 +798,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(response.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -838,11 +807,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act 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 iap = requireOpenIap() val act = activity if (act == null) { safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, "Activity not available") @@ -866,7 +831,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(response.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -883,11 +848,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() val act = activity if (act == null) { safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, "Activity not available") @@ -900,7 +861,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } safe.success(response.toString()) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -914,11 +875,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act scope.launch { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@launch - } + val iap = requireOpenIap() val act = activity if (act == null) { safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, "Activity not available") @@ -938,7 +895,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val success = iap.launchExternalLink(act, launchParams) safe.success(success) } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) } } } @@ -980,11 +937,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act scope.launch { withBillingReady(safe, autoInit = true) { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@withBillingReady - } + val iap = requireOpenIap() // Forward the complete platform payload and let the generated // contract own both parsing and result serialization. @@ -1007,7 +960,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.success(JSONObject(result.toJson()).toString()) } catch (e: Exception) { OpenIapLog.error("verifyPurchase error", e) - safe.error(OpenIapError.VerificationFailed.CODE, "Verification failed: ${e.message}", null) + replyBillingError(safe, e, OpenIapError.VerificationFailed) } } } @@ -1026,11 +979,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act scope.launch { withBillingReady(safe, autoInit = true) { try { - val iap = openIap - if (iap == null) { - safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") - return@withBillingReady - } + val iap = requireOpenIap() // Build props map for OpenIAP val propsMap = mutableMapOf("provider" to provider) @@ -1087,7 +1036,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.success(JSONObject(result.toJson()).toString()) } catch (e: Exception) { OpenIapLog.error("verifyPurchaseWithProvider error", e) - safe.error(OpenIapError.VerificationFailed.CODE, "Verification failed: ${e.message}", null) + replyBillingError(safe, e, OpenIapError.VerificationFailed) } } } @@ -1103,12 +1052,6 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act return params[KEY_PURCHASE_TOKEN] as? String } - /** - * Ensures billing client is ready before executing a block of code. - * This helper reduces duplication of connection checking logic. - * - * @param autoInit If true, automatically initializes connection if not ready - */ private suspend fun withBillingReady( safe: MethodResultWrapper, autoInit: Boolean = false, @@ -1124,7 +1067,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act connectionReady = ok emitConnectionUpdated(ok) if (!ok) { - safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.message, "Failed to initialize connection") + val initError = initConnectionError() + safe.error(initError.code, initError.message, "Failed to initialize connection") return } } else { @@ -1132,17 +1076,43 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act return } } + } catch (e: OpenIapError) { + safe.error(e.code, e.message, serializeOpenIapError(e)) + return } catch (e: Exception) { - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + replyBillingError(safe, e) return } } block() } + private fun initConnectionError(): OpenIapError = + context?.let { OpenIapError.InitConnection.forProvider(it) } ?: OpenIapError.InitConnection + + private fun requireOpenIap(): OpenIapProtocol = openIap ?: run { + val ctx = context ?: throw OpenIapError.NotPrepared + OpenIapProvider.create(ctx).also { + it.setActivity(activity) + openIap = it + } + } + + private fun replyBillingError( + safe: MethodResultWrapper, + error: Exception, + fallback: OpenIapError = OpenIapError.BillingError(), + ) { + if (error is OpenIapError) { + safe.error(error.code, error.message, serializeOpenIapError(error)) + } else { + safe.error(fallback.code, fallback.message, error.message) + } + } + private fun attachListenersIfNeeded() { if (listenersAttached) return - val iap = openIap ?: return + val iap = requireOpenIap() iap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p -> scope.launch { try { diff --git a/libraries/flutter_inapp_purchase/android/src/storeTest/kotlin/io/github/hyochan/flutter_inapp_purchase/ProviderConfigurationTest.kt b/libraries/flutter_inapp_purchase/android/src/storeTest/kotlin/io/github/hyochan/flutter_inapp_purchase/ProviderConfigurationTest.kt new file mode 100644 index 000000000..afb7164ac --- /dev/null +++ b/libraries/flutter_inapp_purchase/android/src/storeTest/kotlin/io/github/hyochan/flutter_inapp_purchase/ProviderConfigurationTest.kt @@ -0,0 +1,159 @@ +@file:Suppress("ktlint:standard:package-name") + +package io.github.hyochan.flutter_inapp_purchase + +import android.content.Context +import android.os.Bundle +import android.os.Looper +import dev.hyo.openiap.DeepLinkOptions +import dev.hyo.openiap.ErrorCode +import dev.hyo.openiap.MutationDeepLinkToSubscriptionsHandler +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.OpenIapProvider +import dev.hyo.openiap.OpenIapProviderFactory +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProviderConfigurationTest { + @Test fun `subscription management works before billing init`() { + val context = RuntimeEnvironment.getApplication() + val info = context.packageManager.getApplicationInfo(context.packageName, 0) + info.metaData = Bundle().apply { putString(OpenIapProvider.METADATA_KEY, SubscriptionManagementFactory::class.java.name) } + shadowOf(context.packageManager).installPackage( + android.content.pm.PackageInfo().apply { + packageName = context.packageName + applicationInfo = info + }, + ) + val messenger = + Proxy.newProxyInstance( + BinaryMessenger::class.java.classLoader, + arrayOf(BinaryMessenger::class.java), + ) { _, _, _ -> null } as BinaryMessenger + val plugin = AndroidInappPurchasePlugin() + plugin.setContext(context) + plugin.setChannel(MethodChannel(messenger, "test")) + SubscriptionManagementFactory.requests.clear() + for (method in listOf("deepLinkToSubscriptionsAndroid", "manageSubscription", "openPlayStoreSubscriptions")) { + var responses = 0 + plugin.onMethodCall( + MethodCall(method, mapOf("skuAndroid" to "sku", "packageNameAndroid" to "example.app")), + object : MethodChannel.Result { + override fun success(result: Any?) { + responses++ + } + + override fun notImplemented() { + fail("Subscription management must be implemented") + } + + override fun error( + code: String, + message: String?, + details: Any?, + ) { + fail("Unexpected $code: $message") + } + }, + ) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, responses) + } + assertEquals(3, SubscriptionManagementFactory.requests.size) + assertEquals("sku", SubscriptionManagementFactory.requests.first()?.skuAndroid) + plugin.dispose() + } + + @Test fun `missing provider reaches init callback after registration`() { + val context = RuntimeEnvironment.getApplication() + val info = context.packageManager.getApplicationInfo(context.packageName, 0) + info.metaData = Bundle() + shadowOf(context.packageManager).installPackage( + android.content.pm.PackageInfo().apply { + packageName = context.packageName + applicationInfo = info + }, + ) + val messenger = + Proxy.newProxyInstance( + BinaryMessenger::class.java.classLoader, + arrayOf(BinaryMessenger::class.java), + ) { _, _, _ -> null } as BinaryMessenger + val plugin = AndroidInappPurchasePlugin() + plugin.setContext(context) + plugin.setChannel(MethodChannel(messenger, "test")) + var errorCode: String? = null + var details: Any? = null + var responses = 0 + for (method in listOf( + "initConnection", + "deepLinkToSubscriptionsAndroid", + "manageSubscription", + "openPlayStoreSubscriptions", + "getStorefront", + )) { + plugin.onMethodCall( + MethodCall(method, null), + object : MethodChannel.Result { + override fun success(result: Any?) { + fail("Missing provider must fail") + } + + override fun notImplemented() { + fail("initConnection must be implemented") + } + + override fun error( + code: String, + message: String?, + errorDetails: Any?, + ) { + assertEquals(ErrorCode.DeveloperError.rawValue, code) + errorCode = code + details = errorDetails + responses++ + } + }, + ) + shadowOf(Looper.getMainLooper()).idle() + } + assertEquals(ErrorCode.DeveloperError.rawValue, errorCode) + assertEquals(5, responses) + assertTrue(details is Map<*, *>) + assertTrue((details as Map<*, *>)["message"].toString().contains("provider", ignoreCase = true)) + plugin.dispose() + } +} + +class SubscriptionManagementFactory : OpenIapProviderFactory { + override val storeId = "test-subscription-management" + override val coreVersion get() = OpenIapProvider.coreVersion + override val clientProtocolVersion = "0.2.0" + + override fun create(context: Context): OpenIapProtocol { + val deepLink: MutationDeepLinkToSubscriptionsHandler = { options -> + requests.add(options) + Unit + } + return Proxy.newProxyInstance(OpenIapProtocol::class.java.classLoader, arrayOf(OpenIapProtocol::class.java)) { _, method, _ -> + if (method.name == "getDeepLinkToSubscriptions") deepLink else null + } as OpenIapProtocol + } + + companion object { + val requests = mutableListOf() + } +} diff --git a/libraries/flutter_inapp_purchase/android/src/storeTest/kotlin/io/github/hyochan/flutter_inapp_purchase/ProviderErrorPropagationTest.kt b/libraries/flutter_inapp_purchase/android/src/storeTest/kotlin/io/github/hyochan/flutter_inapp_purchase/ProviderErrorPropagationTest.kt new file mode 100644 index 000000000..8aa3e1305 --- /dev/null +++ b/libraries/flutter_inapp_purchase/android/src/storeTest/kotlin/io/github/hyochan/flutter_inapp_purchase/ProviderErrorPropagationTest.kt @@ -0,0 +1,95 @@ +@file:Suppress("ktlint:standard:package-name") + +package io.github.hyochan.flutter_inapp_purchase + +import android.os.Looper +import dev.hyo.openiap.MutationVerifyPurchaseHandler +import dev.hyo.openiap.MutationVerifyPurchaseWithProviderHandler +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapProtocol +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProviderErrorPropagationTest { + @Test + fun `verification exposes native unsupported and connection errors with diagnostics`() { + for (error in listOf(OpenIapError.FeatureNotSupported("Use the vendor backend"), OpenIapError.NotPrepared)) { + val verify: MutationVerifyPurchaseHandler = { throw error } + val managed: MutationVerifyPurchaseWithProviderHandler = { throw error } + val provider = + Proxy.newProxyInstance(OpenIapProtocol::class.java.classLoader, arrayOf(OpenIapProtocol::class.java)) { _, method, _ -> + when (method.name) { + "getVerifyPurchase" -> verify + "getVerifyPurchaseWithProvider" -> managed + else -> null + } + } as OpenIapProtocol + val messenger = + Proxy.newProxyInstance( + BinaryMessenger::class.java.classLoader, + arrayOf(BinaryMessenger::class.java), + ) { _, _, _ -> null } as BinaryMessenger + val plugin = AndroidInappPurchasePlugin() + plugin.setContext(RuntimeEnvironment.getApplication()) + plugin.setChannel(MethodChannel(messenger, "test")) + for ((name, value) in mapOf("openIap" to provider, "connectionReady" to true, "listenersAttached" to true)) { + plugin.javaClass + .getDeclaredField(name) + .apply { isAccessible = true } + .set(plugin, value) + } + for (method in listOf("verifyPurchase", "verifyPurchaseWithProvider")) { + val params = + if (method == + "verifyPurchase" + ) { + mapOf( + "google" to + mapOf("sku" to "sku", "accessToken" to "fixture", "packageName" to "test.app", "purchaseToken" to "opaque"), + ) + } else { + mapOf("provider" to "iapkit") + } + var responses = 0 + plugin.onMethodCall( + MethodCall(method, params), + object : MethodChannel.Result { + override fun success(result: Any?) { + fail("Unsupported verification must reject") + } + + override fun notImplemented() { + fail("Verification API must exist") + } + + override fun error( + code: String, + message: String?, + details: Any?, + ) { + assertEquals(error.code, code) + assertEquals(error.message, message) + assertEquals(error.code, (details as Map<*, *>)["code"]) + responses++ + } + }, + ) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, responses) + } + plugin.dispose() + } + } +} diff --git a/libraries/flutter_inapp_purchase/example/android/settings.gradle b/libraries/flutter_inapp_purchase/example/android/settings.gradle index f59e833fb..11a2c521a 100644 --- a/libraries/flutter_inapp_purchase/example/android/settings.gradle +++ b/libraries/flutter_inapp_purchase/example/android/settings.gradle @@ -96,4 +96,8 @@ def localOpenIapProject = new File(settingsDir, '../../../../packages/google/ope if (localOpenIapProject.exists()) { include ':openiap' project(':openiap').projectDir = localOpenIapProject + include ':openiap-core' + project(':openiap-core').projectDir = new File(settingsDir, '../../../../packages/google/core') + include ':openiap-conformance' + project(':openiap-conformance').projectDir = new File(settingsDir, '../../../../packages/conformance/android') } diff --git a/libraries/flutter_inapp_purchase/example/lib/src/constants.dart b/libraries/flutter_inapp_purchase/example/lib/src/constants.dart index 422170607..1b4eac8cf 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/constants.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/constants.dart @@ -36,9 +36,11 @@ class IapConstants { /// compiled in, so keep it out of the define in the first place. static String _rejectSecretKey(String apiKey) { if (!apiKey.startsWith('openiap-kit_sk_')) return apiKey; - debugPrint('[IapConstants] api key is a secret sk_ key; use an openiap-kit_pk_ key'); + debugPrint( + '[IapConstants] api key is a secret sk_ key; use an openiap-kit_pk_ key'); return ''; } + /// Origin of a local IAPKit server; empty selects the hosted default. static String get iapkitBaseUrl => _hasIapkitBaseUrlDefine ? _iapkitBaseUrlFromEnvironment diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/purchase_flow_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/purchase_flow_screen.dart index 6c7f2d30e..113a0be94 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/purchase_flow_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/purchase_flow_screen.dart @@ -32,7 +32,8 @@ extension VerificationMethodX on VerificationMethod { } /// Mirrors the other examples: no key skips, a local origin prefers it. -VerificationMethod defaultVerificationMethod(String apiKey, String localBaseUrl) { +VerificationMethod defaultVerificationMethod( + String apiKey, String localBaseUrl) { if (apiKey.trim().isEmpty) return VerificationMethod.ignore; if (localBaseUrl.trim().isNotEmpty) return VerificationMethod.iapkitLocalhost; return VerificationMethod.iapkit; @@ -84,8 +85,9 @@ class _PurchaseFlowScreenState extends State { }); try { - // End any existing connection first to reset configuration - // This ensures we start fresh without alternative billing settings + await _purchaseUpdatedSubscription?.cancel(); + await _purchaseErrorSubscription?.cancel(); + // Reset alternative billing settings before connecting. try { await _iap.endConnection(); await Future.delayed(const Duration(milliseconds: 100)); @@ -103,6 +105,25 @@ class _PurchaseFlowScreenState extends State { _setupPurchaseListeners(); await _loadProducts(); + if (!kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS)) { + try { + final pending = await _iap.getPendingTransactionsIOS(); + for (final purchase in pending) { + if (!mounted) return; + await _handlePurchaseUpdate(purchase); + } + } catch (error) { + if (error is! PurchaseError || + error.code != ErrorCode.FeatureNotSupported) { + if (!mounted) return; + setState(() { + _purchaseResult = 'Pending purchase recovery failed: $error'; + }); + } + } + } } catch (e) { debugPrint('Failed to initialize IAP connection: $e'); } finally { @@ -268,8 +289,7 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt // A redelivery can land while this attempt is still verifying, so claim // the id now and release it only if this attempt does not finish. - if (transactionId != null && - !_processedTransactionIds.add(transactionId)) { + if (transactionId != null && !_processedTransactionIds.add(transactionId)) { debugPrint('⚠️ Transaction already in progress: $transactionId'); return; } diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart index c4625d134..22b0756c9 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart @@ -34,7 +34,8 @@ extension VerificationMethodX on VerificationMethod { } /// Mirrors the other examples: no key skips, a local origin prefers it. -VerificationMethod defaultVerificationMethod(String apiKey, String localBaseUrl) { +VerificationMethod defaultVerificationMethod( + String apiKey, String localBaseUrl) { if (apiKey.trim().isEmpty) return VerificationMethod.ignore; if (localBaseUrl.trim().isNotEmpty) return VerificationMethod.iapkitLocalhost; return VerificationMethod.iapkit; diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift index 95a72e015..59af1ef9a 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift @@ -12,7 +12,7 @@ let package = Package( ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), - .package(url: "https://github.com/hyodotdev/openiap.git", from: "3.6.1"), + .package(url: "https://github.com/hyodotdev/openiap.git", from: "4.0.0"), ], targets: [ .target( diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index 62d35c696..11eb42e57 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -128,6 +128,19 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { getAvailableItems(result: result) } + case "hasActiveSubscriptions": + let ids = (call.arguments as? [String: Any])?["subscriptionIds"] as? [String] + Task { + do { + let active = try await OpenIapModule.shared.hasActiveSubscriptions(ids) + await MainActor.run { result(active) } + } catch let error as PurchaseError { + await MainActor.run { result(self.flutterError(from: error)) } + } catch { + await MainActor.run { result(FlutterError(code: ErrorCode.serviceError.rawValue, message: error.localizedDescription, details: nil)) } + } + } + case "getActiveSubscriptions": if let subscriptionIds = call.arguments as? [String] { getActiveSubscriptions(subscriptionIds: subscriptionIds, result: result) @@ -271,6 +284,18 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(FlutterError(code: code.rawValue, message: "getAppTransactionIOS requires iOS 16.0+", details: nil)) } + case "restorePurchases": + Task { + do { + try await OpenIapModule.shared.restorePurchases() + await MainActor.run { result(true) } + } catch let error as PurchaseError { + await MainActor.run { result(self.flutterError(from: error)) } + } catch { + await MainActor.run { result(FlutterError(code: ErrorCode.syncError.rawValue, message: error.localizedDescription, details: nil)) } + } + } + case "syncIOS": syncIOS(result: result) @@ -394,8 +419,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { setupOpenIapListeners() Task { @MainActor in do { - _ = try await OpenIapModule.shared.initConnection() - result(nil) + let connected = try await OpenIapModule.shared.initConnection() + result(connected) } catch let purchaseError as PurchaseError { FlutterIapLog.failure("initConnection", error: purchaseError) result(flutterError(from: purchaseError)) @@ -411,9 +436,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.debug("endConnection called") Task { @MainActor in do { - _ = try await OpenIapModule.shared.endConnection() - removeOpenIapListeners() - result(nil) + let disconnected = try await OpenIapModule.shared.endConnection() + if disconnected { removeOpenIapListeners() } + result(disconnected) } catch let purchaseError as PurchaseError { FlutterIapLog.failure("endConnection", error: purchaseError) result(flutterError(from: purchaseError)) @@ -648,7 +673,10 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { purchase = try FlutterIapHelper.decodePurchaseInput(from: purchaseDict) } catch let purchaseError as PurchaseError { FlutterIapLog.failure("finishTransactionDecode", error: purchaseError) - if let transactionId = resolveTransactionId(from: purchaseDict) { + if OpenIapModule.shared.storeId == StoreIds.Apple, + purchaseDict["store"] == nil || purchaseDict["store"] as? String == "apple", + purchaseDict["storeId"] == nil || purchaseDict["storeId"] as? String == StoreIds.Apple, + let transactionId = resolveTransactionId(from: purchaseDict) { finishTransaction(transactionId: transactionId, result: result) return } @@ -656,7 +684,10 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { return } catch { FlutterIapLog.failure("finishTransactionDecode", error: error) - if let transactionId = resolveTransactionId(from: purchaseDict) { + if OpenIapModule.shared.storeId == StoreIds.Apple, + purchaseDict["store"] == nil || purchaseDict["store"] as? String == "apple", + purchaseDict["storeId"] == nil || purchaseDict["storeId"] as? String == StoreIds.Apple, + let transactionId = resolveTransactionId(from: purchaseDict) { finishTransaction(transactionId: transactionId, result: result) return } diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index d1b069c93..d68f4a1de 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -489,7 +489,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } try { - await _channel.invokeMethod('endConnection'); + final disconnected = + await _channel.invokeMethod('endConnection'); + if (disconnected == false) return false; _isInitialized = false; _isIapUnavailable = false; @@ -1691,6 +1693,14 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { final transactionId = purchase.id; if (_platform.isAndroid) { + if (purchase.store == gentype.IapStore.Unknown) { + await _channel.invokeMethod('finishTransaction', { + 'purchase': purchase.toJson(), + 'isConsumable': consumable, + }); + unawaited(_firstPurchaseNotice.onFinished(purchase)); + return; + } if (purchase.purchaseToken == null || purchase.purchaseToken!.isEmpty) { throw PurchaseError( @@ -1997,14 +2007,15 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } } - return gentype.RequestVerifyPurchaseWithIapkitResult( - clientPayload: clientPayload, - environment: environment, - isValid: isValid, - productId: productIdValue as String?, - state: parseState(), - store: gentype.IapStore.fromJson(store.toString()), - ); + return gentype.RequestVerifyPurchaseWithIapkitResult.fromJson({ + ...itemMap, + 'clientPayload': clientPayload?.toJson(), + 'environment': environment, + 'isValid': isValid, + 'productId': productIdValue as String?, + 'state': parseState().toJson(), + 'store': store.toString(), + }); } gentype.RequestVerifyPurchaseWithIapkitResult? iapkitResult; @@ -2042,11 +2053,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ), ); } on PlatformException catch (error) { - throw PurchaseError( - code: gentype.ErrorCode.PurchaseVerificationFailed, - message: - 'Failed to verify purchase [${error.code}]: ${error.message ?? error.details}', - ); + throw _purchaseErrorFromPlatformException( + error, 'verify purchase'); } catch (error) { if (error is PurchaseError) rethrow; throw PurchaseError( @@ -2115,11 +2123,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { return gentype.VerifyPurchaseResult.fromJson(resultMap); } on PlatformException catch (error) { - throw PurchaseError( - code: gentype.ErrorCode.PurchaseVerificationFailed, - message: - 'Failed to verify purchase [${error.code}]: ${error.message ?? error.details}', - ); + throw _purchaseErrorFromPlatformException(error, 'verify purchase'); } catch (error) { if (error is PurchaseError) rethrow; throw PurchaseError( @@ -2286,18 +2290,23 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// See: https://openiap.dev/docs/apis/restore-purchases gentype.MutationRestorePurchasesHandler get restorePurchases => () async { try { - if (_platform.isIOS || _platform.isMacOS) { - final synced = await syncIOS(); + if (_platform.isIOS || _platform.isMacOS || _platform.isAndroid) { + final synced = + await _channel.invokeMethod('restorePurchases') ?? false; if (!synced) { throw PurchaseError( code: gentype.ErrorCode.SyncError, - message: 'App Store sync did not complete', + message: 'Store purchase restore did not complete', ); } } await getAvailablePurchases(); } catch (error) { if (error is PurchaseError) rethrow; + if (error is PlatformException) { + throw _purchaseErrorFromPlatformException( + error, 'restore purchases'); + } throw PurchaseError( code: gentype.ErrorCode.SyncError, message: 'Failed to restore purchases: ${error.toString()}', @@ -2461,21 +2470,19 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// See: https://openiap.dev/docs/apis/has-active-subscriptions gentype.QueryHasActiveSubscriptionsHandler get hasActiveSubscriptions => ([subscriptionIds]) async { - final activeSubscriptions = await getActiveSubscriptions( - subscriptionIds, - ); - // For Android, also call native with explicit type for parity/logging - if (_platform.isAndroid) { - try { - await _channel.invokeMethod( - 'getAvailableItems', - {'type': 'subs'}, - ); - } catch (_) { - // Ignore; this is for logging/compatibility only - } + if (!_platform.isAndroid && !_platform.isIOS && !_platform.isMacOS) { + return false; + } + try { + return await _channel.invokeMethod( + 'hasActiveSubscriptions', { + 'subscriptionIds': subscriptionIds, + }) ?? + false; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, 'check active subscriptions'); } - return activeSubscriptions.isNotEmpty; }; // MARK: - Billing Programs API (Android 8.2.0+) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index b0e9ea450..0f56bbef2 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -451,11 +451,15 @@ bool _isValidAuthoritativePurchaseMap( final transactionDate = value['transactionDate']; if (transactionDate is! num || !transactionDate.isFinite) return false; final store = value['store']; - if (platformIsIOS && store != 'apple') return false; + if (platformIsIOS && store != 'apple' && store != 'unknown') return false; if (platformIsAndroid && store != 'google' && store != 'amazon' && - store != 'horizon') { + store != 'horizon' && + !(store == 'unknown' && + value['storeId'] is String && + RegExp(r'^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$') + .hasMatch(value['storeId'] as String))) { return false; } final quantity = value['quantity']; diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 913b0c81c..9345facb8 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -7,6 +7,28 @@ import 'dart:async'; +abstract final class StoreIds { + static const String apple = 'apple'; + static const String play = 'play'; + static const String horizon = 'horizon'; + static const String amazon = 'amazon'; +} + +String _resolveStoreId(IapStore store, dynamic value) { + final official = switch (store) { + IapStore.Apple => 'apple', + IapStore.Google => 'play', + IapStore.Horizon => 'horizon', + IapStore.Amazon => 'amazon', + IapStore.Unknown => null, + }; + final id = value ?? official; + if (id is! String || (official != null ? id != official : RegExp(r'^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$').firstMatch(id)?.end != id.length || const {'auto', 'none', 'unknown', 'apple', 'play', 'google', 'horizon', 'amazon'}.contains(id))) { + throw const FormatException('Invalid store identity'); + } + return id; +} + // MARK: - Enums /// Play Billing choice image layout (Android) @@ -803,6 +825,7 @@ enum IapPlatform { String toJson() => value; } +/// Frozen legacy store discriminator. Use storeId for extensible store identity. enum IapStore { Unknown('unknown'), Apple('apple'), @@ -1518,6 +1541,7 @@ abstract class PurchaseCommon { String? get currentPlanId; String get id; List? get ids; + /// Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. bool get isAutoRenewing; String get productId; PurchaseState get purchaseState; @@ -1526,6 +1550,8 @@ abstract class PurchaseCommon { int get quantity; /// Store where purchase was made IapStore get store; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + String get storeId; /// Unix timestamp in milliseconds since January 1, 1970 UTC. double get transactionDate; } @@ -1555,6 +1581,7 @@ class ActiveSubscription { required this.transactionId, }); + /// Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. final bool? autoRenewingAndroid; final String? basePlanIdAndroid; /// The current plan identifier. This is: @@ -3128,6 +3155,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { required this.quantity, this.signatureAndroid, required this.store, + required this.storeId, required this.transactionDate, this.transactionId, this.userIdAmazon, @@ -3135,6 +3163,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { this.isAlternativeBilling, }); + /// Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. final bool? autoRenewingAndroid; final String? currentPlanId; final String? dataAndroid; @@ -3142,6 +3171,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { final String id; final List? ids; final bool? isAcknowledgedAndroid; + /// Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. final bool isAutoRenewing; /// Whether the subscription is suspended (Android) /// A suspended subscription means the user's payment method failed and they need to fix it. @@ -3164,6 +3194,8 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { final String? signatureAndroid; /// Store where purchase was made final IapStore store; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + final String storeId; /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String? transactionId; @@ -3177,6 +3209,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { final bool? isAlternativeBilling; factory PurchaseAndroid.fromJson(Map json) { + final store = IapStore.fromJson(json['store'] as String); return PurchaseAndroid( autoRenewingAndroid: json['autoRenewingAndroid'] as bool?, currentPlanId: json['currentPlanId'] as String?, @@ -3196,7 +3229,8 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { purchaseToken: json['purchaseToken'] as String?, quantity: json['quantity'] as int, signatureAndroid: json['signatureAndroid'] as String?, - store: IapStore.fromJson(json['store'] as String), + store: store, + storeId: _resolveStoreId(store, json['storeId']), transactionDate: (json['transactionDate'] as num).toDouble(), transactionId: json['transactionId'] as String?, userIdAmazon: json['userIdAmazon'] as String?, @@ -3228,6 +3262,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { 'quantity': quantity, 'signatureAndroid': signatureAndroid, 'store': store.toJson(), + 'storeId': storeId, 'transactionDate': transactionDate, 'transactionId': transactionId, 'userIdAmazon': userIdAmazon, @@ -3329,6 +3364,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { this.revocationTypeIOS, required this.store, this.storefrontCountryCodeIOS, + required this.storeId, this.subscriptionGroupIdIOS, required this.transactionDate, required this.transactionId, @@ -3364,6 +3400,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { final double? expirationDateIOS; final String id; final List? ids; + /// Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. final bool isAutoRenewing; final bool? isUpgradedIOS; final PurchaseOfferIOS? offerIOS; @@ -3391,6 +3428,8 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { /// Store where purchase was made final IapStore store; final String? storefrontCountryCodeIOS; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + final String storeId; final String? subscriptionGroupIdIOS; /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; @@ -3400,6 +3439,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { final bool? isAlternativeBilling; factory PurchaseIOS.fromJson(Map json) { + final store = IapStore.fromJson(json['store'] as String); return PurchaseIOS( advancedCommerceInfoIOS: json['advancedCommerceInfoIOS'] != null ? AdvancedCommerceInfoIOS.fromJson(json['advancedCommerceInfoIOS'] as Map) : null, appAccountToken: json['appAccountToken'] as String?, @@ -3436,8 +3476,9 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { revocationDateIOS: (json['revocationDateIOS'] as num?)?.toDouble(), revocationReasonIOS: json['revocationReasonIOS'] as String?, revocationTypeIOS: json['revocationTypeIOS'] as String?, - store: IapStore.fromJson(json['store'] as String), + store: store, storefrontCountryCodeIOS: json['storefrontCountryCodeIOS'] as String?, + storeId: _resolveStoreId(store, json['storeId']), subscriptionGroupIdIOS: json['subscriptionGroupIdIOS'] as String?, transactionDate: (json['transactionDate'] as num).toDouble(), transactionId: json['transactionId'] as String, @@ -3488,6 +3529,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { 'revocationTypeIOS': revocationTypeIOS, 'store': store.toJson(), 'storefrontCountryCodeIOS': storefrontCountryCodeIOS, + 'storeId': storeId, 'subscriptionGroupIdIOS': subscriptionGroupIdIOS, 'transactionDate': transactionDate, 'transactionId': transactionId, @@ -3750,6 +3792,7 @@ class RequestVerifyPurchaseWithIapkitResult { this.productId, required this.state, required this.store, + required this.storeId, }); /// Available in OpenIAP 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. @@ -3777,15 +3820,19 @@ class RequestVerifyPurchaseWithIapkitResult { /// The current state of the purchase. final IapkitPurchaseState state; final IapStore store; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + final String storeId; factory RequestVerifyPurchaseWithIapkitResult.fromJson(Map json) { + final store = IapStore.fromJson(json['store'] as String); return RequestVerifyPurchaseWithIapkitResult( clientPayload: json['clientPayload'] is Map ? IapkitProductClientPayload._tryFromJson(json['clientPayload'] as Map) : null, environment: json['environment'] as String?, isValid: json['isValid'] as bool, productId: json['productId'] as String?, state: IapkitPurchaseState.fromJson(json['state'] as String), - store: IapStore.fromJson(json['store'] as String), + store: store, + storeId: _resolveStoreId(store, json['storeId']), ); } @@ -3798,6 +3845,47 @@ class RequestVerifyPurchaseWithIapkitResult { 'productId': productId, 'state': state.toJson(), 'store': store.toJson(), + 'storeId': storeId, + }; + } +} + +/// Store-provider contract shared by the Apple and Android native bindings. +/// coreVersion names the native contract build; clientProtocolVersion names the +/// Client Protocol build. Capabilities use the conformance provider profile ids. +class StoreProviderDescriptor { + const StoreProviderDescriptor({ + required this.capabilities, + required this.clientProtocolVersion, + required this.coreVersion, + required this.platform, + required this.storeId, + }); + + final List capabilities; + final String clientProtocolVersion; + final String coreVersion; + final IapPlatform platform; + final String storeId; + + factory StoreProviderDescriptor.fromJson(Map json) { + return StoreProviderDescriptor( + capabilities: (json['capabilities'] as List).map((e) => e as String).toList(), + clientProtocolVersion: json['clientProtocolVersion'] as String, + coreVersion: json['coreVersion'] as String, + platform: IapPlatform.fromJson(json['platform'] as String), + storeId: json['storeId'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'StoreProviderDescriptor', + 'capabilities': capabilities, + 'clientProtocolVersion': clientProtocolVersion, + 'coreVersion': coreVersion, + 'platform': platform.toJson(), + 'storeId': storeId, }; } } @@ -5012,10 +5100,8 @@ class _SubsPurchase extends RequestPurchaseProps { /// Platform-specific purchase request parameters. /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. -/// - apple: Always targets App Store -/// - google: Targets Play Store by default, Horizon when built with horizon flavor, -/// or Fire OS when built with amazon flavor -/// (determined at build time, not runtime) +/// - apple: Uses the selected Apple-platform provider (App Store by default) +/// - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) class RequestPurchasePropsByPlatforms { const RequestPurchasePropsByPlatforms({ this.apple, @@ -5190,10 +5276,8 @@ class RequestSubscriptionIosProps { /// Platform-specific subscription request parameters. /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. -/// - apple: Always targets App Store -/// - google: Targets Play Store by default, Horizon when built with horizon flavor, -/// or Fire OS when built with amazon flavor -/// (determined at build time, not runtime) +/// - apple: Uses the selected Apple-platform provider (App Store by default) +/// - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) class RequestSubscriptionPropsByPlatforms { const RequestSubscriptionPropsByPlatforms({ this.apple, @@ -5749,6 +5833,7 @@ sealed class Purchase implements PurchaseCommon { String get id; @override List? get ids; + /// Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. @override bool get isAutoRenewing; @override @@ -5763,6 +5848,9 @@ sealed class Purchase implements PurchaseCommon { /// Store where purchase was made @override IapStore get store; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + @override + String get storeId; /// Unix timestamp in milliseconds since January 1, 1970 UTC. @override double get transactionDate; @@ -5916,6 +6004,7 @@ abstract class MutationResolver { /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios Future presentExternalPurchaseNoticeSheetIOS(); /// Initiate a purchase or subscription flow; rely on events for final state. + /// Providers emit one canonical purchase-error event before returning or throwing a request failure. /// See: https://openiap.dev/docs/apis/request-purchase Future requestPurchase(RequestPurchaseProps params); /// Restore non-consumable and active subscription purchases. diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift index 6676785b1..beea30925 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift @@ -12,7 +12,7 @@ let package = Package( ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), - .package(url: "https://github.com/hyodotdev/openiap.git", from: "3.6.1"), + .package(url: "https://github.com/hyodotdev/openiap.git", from: "4.0.0"), ], targets: [ .target( diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index b2a1e5c6f..4d3cbbd47 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -135,6 +135,19 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { getAvailableItems(result: result) } + case "hasActiveSubscriptions": + let ids = (call.arguments as? [String: Any])?["subscriptionIds"] as? [String] + Task { + do { + let active = try await OpenIapModule.shared.hasActiveSubscriptions(ids) + await MainActor.run { result(active) } + } catch let error as PurchaseError { + await MainActor.run { result(self.flutterError(from: error)) } + } catch { + await MainActor.run { result(FlutterError(code: ErrorCode.serviceError.rawValue, message: error.localizedDescription, details: nil)) } + } + } + case "getActiveSubscriptions": if let subscriptionIds = call.arguments as? [String] { getActiveSubscriptions(subscriptionIds: subscriptionIds, result: result) @@ -238,6 +251,18 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { case "getAppTransactionIOS": getAppTransactionIOS(result: result) + case "restorePurchases": + Task { + do { + try await OpenIapModule.shared.restorePurchases() + await MainActor.run { result(true) } + } catch let error as PurchaseError { + await MainActor.run { result(self.flutterError(from: error)) } + } catch { + await MainActor.run { result(FlutterError(code: ErrorCode.syncError.rawValue, message: error.localizedDescription, details: nil)) } + } + } + case "syncIOS": syncIOS(result: result) @@ -349,8 +374,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { setupOpenIapListeners() Task { @MainActor in do { - _ = try await OpenIapModule.shared.initConnection() - result(nil) + let connected = try await OpenIapModule.shared.initConnection() + result(connected) } catch let purchaseError as PurchaseError { FlutterIapLog.failure("initConnection", error: purchaseError) result(flutterError(from: purchaseError)) @@ -366,9 +391,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.debug("endConnection called") Task { @MainActor in do { - _ = try await OpenIapModule.shared.endConnection() - removeOpenIapListeners() - result(nil) + let disconnected = try await OpenIapModule.shared.endConnection() + if disconnected { removeOpenIapListeners() } + result(disconnected) } catch let purchaseError as PurchaseError { FlutterIapLog.failure("endConnection", error: purchaseError) result(flutterError(from: purchaseError)) @@ -602,7 +627,10 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { purchase = try FlutterIapHelper.decodePurchaseInput(from: purchaseDict) } catch let purchaseError as PurchaseError { FlutterIapLog.failure("finishTransactionDecode", error: purchaseError) - if let transactionId = resolveTransactionId(from: purchaseDict) { + if OpenIapModule.shared.storeId == StoreIds.Apple, + purchaseDict["store"] == nil || purchaseDict["store"] as? String == "apple", + purchaseDict["storeId"] == nil || purchaseDict["storeId"] as? String == StoreIds.Apple, + let transactionId = resolveTransactionId(from: purchaseDict) { finishTransaction(transactionId: transactionId, result: result) return } @@ -610,7 +638,10 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { return } catch { FlutterIapLog.failure("finishTransactionDecode", error: error) - if let transactionId = resolveTransactionId(from: purchaseDict) { + if OpenIapModule.shared.storeId == StoreIds.Apple, + purchaseDict["store"] == nil || purchaseDict["store"] as? String == "apple", + purchaseDict["storeId"] == nil || purchaseDict["storeId"] as? String == StoreIds.Apple, + let transactionId = resolveTransactionId(from: purchaseDict) { finishTransaction(transactionId: transactionId, result: result) return } 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 d56503cb2..97dfede59 100755 --- a/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh +++ b/libraries/flutter_inapp_purchase/scripts/verify-android-consumer-build.sh @@ -15,11 +15,16 @@ trap cleanup EXIT package_copy="$tmp_root/flutter_inapp_purchase" local_google_copy="$tmp_root/packages/google/openiap" +local_core_copy="$tmp_root/packages/google/core" consumer_app="$tmp_root/openiap_consumer_smoke" +conformance_copy="$tmp_root/packages/conformance/src/spec" cp -R "$package_root" "$tmp_root/" mkdir -p "$(dirname "$local_google_copy")" cp -R "$repo_root/packages/google/openiap" "$local_google_copy" +cp -R "$repo_root/packages/google/core" "$local_core_copy" +mkdir -p "$conformance_copy" +cp "$repo_root/packages/conformance/src/spec/suite-version.mjs" "$conformance_copy/" rm -rf \ "$package_copy/.build" \ "$package_copy/.dart_tool" \ @@ -31,7 +36,9 @@ rm -rf \ "$package_copy/example/android/build" \ "$package_copy/example/build" \ "$local_google_copy/.gradle" \ - "$local_google_copy/build" + "$local_google_copy/build" \ + "$local_core_copy/.gradle" \ + "$local_core_copy/build" rm -f "$package_copy/openiap-versions.json" "$package_copy/android/openiap-store.gradle" cp "$repo_root/openiap-versions.json" "$package_copy/openiap-versions.json" @@ -79,6 +86,8 @@ if [ -f "$consumer_app/android/settings.gradle.kts" ]; then include(":openiap") project(":openiap").projectDir = file("../../packages/google/openiap") +include(":openiap-core") +project(":openiap-core").projectDir = file("../../packages/google/core") EOF elif [ -f "$consumer_app/android/settings.gradle" ]; then settings_file="$consumer_app/android/settings.gradle" @@ -87,6 +96,8 @@ elif [ -f "$consumer_app/android/settings.gradle" ]; then include ':openiap' project(':openiap').projectDir = file('../../packages/google/openiap') +include ':openiap-core' +project(':openiap-core').projectDir = file('../../packages/google/core') EOF else echo "Failed to locate generated Android settings.gradle(.kts) in $consumer_app/android" >&2 diff --git a/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart b/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart index 38c6ca862..73e891238 100644 --- a/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart +++ b/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart @@ -664,6 +664,7 @@ types.PurchaseAndroid _androidPurchase(String? token) { purchaseToken: token, quantity: 1, store: types.IapStore.Google, + storeId: 'play', transactionDate: 1, ); } @@ -676,6 +677,7 @@ types.PurchaseIOS _iosPurchase() { purchaseState: types.PurchaseState.Purchased, quantity: 1, store: types.IapStore.Apple, + storeId: 'apple', transactionDate: 1, transactionId: 'apple-transaction', ); @@ -690,6 +692,7 @@ Map _androidPurchaseJson(String token) { 'purchaseToken': token, 'quantity': 1, 'store': 'google', + 'storeId': 'play', 'transactionDate': 1, }; } diff --git a/libraries/flutter_inapp_purchase/test/errors_unit_test.dart b/libraries/flutter_inapp_purchase/test/errors_unit_test.dart index dd143bbb4..ab1e3c63f 100644 --- a/libraries/flutter_inapp_purchase/test/errors_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/errors_unit_test.dart @@ -246,6 +246,73 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + for (final operatingSystem in ['android', 'ios']) { + for (final method in ['verifyPurchase', 'verifyPurchaseWithProvider']) { + for (final code in [ + types.ErrorCode.FeatureNotSupported, + types.ErrorCode.NotPrepared, + ]) { + test('$method preserves $code on $operatingSystem', () async { + debugDefaultTargetPlatformOverride = operatingSystem == 'android' + ? TargetPlatform.android + : TargetPlatform.iOS; + var verificationCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initConnection') return true; + if (call.method == method) { + verificationCalls++; + throw PlatformException( + code: code.toJson(), + message: 'Provider verification unavailable', + details: { + 'debugMessage': 'Community provider rejection', + 'productId': 'test.sku', + }, + ); + } + return null; + }); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: operatingSystem), + ); + await iap.initConnection(); + final result = method == 'verifyPurchase' + ? iap.verifyPurchase( + apple: operatingSystem == 'ios' + ? const types.VerifyPurchaseAppleOptions( + sku: 'test.sku') + : null, + google: operatingSystem == 'android' + ? const types.VerifyPurchaseGoogleOptions( + accessToken: 'test-access', + packageName: 'test.app', + purchaseToken: 'test-token', + sku: 'test.sku', + ) + : null, + ) + : iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + ); + await expectLater( + result, + throwsA( + isA() + .having((error) => error.code, 'code', code) + .having((error) => error.debugMessage, 'debugMessage', + 'Community provider rejection') + .having( + (error) => error.productId, 'productId', 'test.sku'), + ), + ); + expect(verificationCalls, 1); + await iap.endConnection(); + }); + } + } + } + test( 'requestPurchase maps user-cancelled PlatformException to ' 'ErrorCode.UserCancelled', diff --git a/libraries/flutter_inapp_purchase/test/first_purchase_notice_test.dart b/libraries/flutter_inapp_purchase/test/first_purchase_notice_test.dart index 1f1e0c5b2..355423499 100644 --- a/libraries/flutter_inapp_purchase/test/first_purchase_notice_test.dart +++ b/libraries/flutter_inapp_purchase/test/first_purchase_notice_test.dart @@ -203,6 +203,7 @@ types.PurchaseAndroid _android({ purchaseToken: token, quantity: 1, store: types.IapStore.Google, + storeId: 'play', transactionDate: 1, ); } @@ -217,6 +218,7 @@ types.PurchaseIOS _apple({ purchaseState: state, quantity: 1, store: types.IapStore.Apple, + storeId: 'apple', transactionDate: 1, transactionId: 'apple-transaction', ); diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart index 203b486c0..ae7194365 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart @@ -19,6 +19,7 @@ void main() { { 'platform': 'android', 'store': 'google', + 'storeId': 'play', 'purchaseStateAndroid': 1, }, ], @@ -46,6 +47,7 @@ void main() { 'purchaseStateAndroid': 1, 'purchaseToken': 'secret-canonical-token', 'store': 'google', + 'storeId': 'play', }, ], platformIsAndroid: true, @@ -392,6 +394,10 @@ void main() { switch (call.method) { case 'initConnection': return true; + case 'hasActiveSubscriptions': + throw PlatformException( + code: 'billing-response-json-parse-error', + message: 'Invalid status'); case 'getActiveSubscriptions': return >[ { 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 8091d40e1..aefa737ba 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 @@ -531,6 +531,7 @@ void main() { 'isAutoRenewing': true, 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', }; } return null; @@ -1099,6 +1100,22 @@ void main() { expect(endCount, 1); }); + test('endConnection preserves false and allows a provider retry', () async { + final iap = + FlutterInappPurchase.private(FakePlatform(operatingSystem: 'ios')); + var disconnects = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initConnection') return true; + if (call.method == 'endConnection') return ++disconnects > 1; + return null; + }); + expect(await iap.initConnection(), isTrue); + expect(await iap.endConnection(), isFalse); + expect(await iap.endConnection(), isTrue); + expect(disconnects, 2); + }); + test('endConnection returns false when not initialized', () async { bool endCalled = false; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -1670,6 +1687,7 @@ void main() { { 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', 'id': 'txn-123', 'productId': 'iap.premium', 'transactionId': 'txn-123', @@ -1680,6 +1698,7 @@ void main() { { 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', 'productId': '', 'transactionId': null, }, @@ -1858,6 +1877,7 @@ void main() { { 'platform': 'android', 'store': 'google', + 'storeId': 'play', 'id': 'txn-android', 'productId': 'coins.100', 'transactionId': 'txn-android', @@ -1874,6 +1894,7 @@ void main() { { 'platform': 'android', 'store': 'google', + 'storeId': 'play', 'productId': '', }, ]; @@ -1960,6 +1981,7 @@ void main() { jsonEncode({ 'platform': 'android', 'store': 'google', + 'storeId': 'play', 'id': 'txn-listener-android', 'productId': 'premium_monthly', 'transactionId': 'txn-listener-android', @@ -2010,6 +2032,7 @@ void main() { final purchasePayload = { 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', 'id': 'txn-456', 'productId': 'iap.premium', 'transactionId': 'txn-456', @@ -2068,6 +2091,7 @@ void main() { final purchasePayload = { 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', 'id': 'txn-dedupe-replay', 'productId': 'iap.premium', 'transactionId': 'txn-dedupe-replay', @@ -2189,6 +2213,7 @@ void main() { final purchasePayload = { 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', 'id': 'txn-handler-dedupe-replay', 'productId': 'iap.premium', 'transactionId': 'txn-handler-dedupe-replay', @@ -2391,7 +2416,7 @@ void main() { switch (call.method) { case 'initConnection': return true; - case 'syncIOS': + case 'restorePurchases': syncCalls += 1; return true; case 'getAvailableItems': @@ -2421,7 +2446,7 @@ void main() { switch (call.method) { case 'initConnection': return true; - case 'syncIOS': + case 'restorePurchases': syncCalls += 1; return true; case 'getAvailableItems': @@ -2451,7 +2476,7 @@ void main() { if (call.method == 'initConnection') { return true; } - if (call.method == 'syncIOS') { + if (call.method == 'restorePurchases') { throw PlatformException(code: '500', message: 'boom'); } if (call.method == 'getAvailableItems') { @@ -2472,7 +2497,7 @@ void main() { isA().having( (error) => error.message, 'message', - contains('sync iOS purchases'), + contains('restore purchases'), ), ), ); @@ -2486,7 +2511,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { if (call.method == 'initConnection') return true; - if (call.method == 'syncIOS') return false; + if (call.method == 'restorePurchases') return false; if (call.method == 'getAvailableItems') { availableCalls += 1; return >[]; @@ -2512,7 +2537,8 @@ void main() { expect(availableCalls, 0); }); - test('restorePurchases fetches purchases directly on Android', () async { + test('restorePurchases restores then fetches purchases on Android', + () async { int availableCalls = 0; int endCalls = 0; @@ -2521,6 +2547,7 @@ void main() { if (call.method == 'initConnection') { return true; } + if (call.method == 'restorePurchases') return true; if (call.method == 'endConnection') { endCalls += 1; return true; @@ -2546,7 +2573,10 @@ void main() { test('restorePurchases propagates available-purchase errors', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { - if (call.method == 'initConnection') return true; + if (call.method == 'initConnection' || + call.method == 'restorePurchases') { + return true; + } if (call.method == 'getAvailableItems') { throw PlatformException( code: 'service-error', @@ -2661,6 +2691,7 @@ void main() { 'purchaseState': 'purchased', 'quantity': 1, 'store': 'apple', + 'storeId': 'apple', 'transactionDate': 1705315800000.0, 'transactionId': 'ios-transaction-id', }, @@ -2924,6 +2955,7 @@ void main() { 'productId': 'premium.monthly', 'state': 'entitled', 'store': 'apple', + 'storeId': 'apple', 'clientPayload': { 'format': 'toml', 'body': 'tier = "gold"', @@ -2999,6 +3031,7 @@ void main() { 'isValid': true, 'state': 'entitled', 'store': 'apple', + 'storeId': 'apple', }, }; } @@ -3049,6 +3082,7 @@ void main() { 'productId': 'premium.monthly', 'state': 'entitled', 'store': 'horizon', + 'storeId': 'horizon', }, }; } @@ -3103,6 +3137,7 @@ void main() { 'isValid': true, 'state': 'pending-acknowledgment', 'store': 'google', + 'storeId': 'play', }, }); } @@ -3167,6 +3202,7 @@ void main() { 'isValid': true, 'state': 'entitled', 'store': 'amazon', + 'storeId': 'amazon', 'environment': environment, }, }); @@ -3319,6 +3355,7 @@ void main() { 'isValid': true, 'state': 'entitled', 'store': 'apple', + 'storeId': 'apple', }, }; } @@ -3364,6 +3401,7 @@ void main() { 'isValid': false, 'state': 'expired', 'store': 'apple', + 'storeId': 'apple', }, 'errors': [ { @@ -3459,6 +3497,7 @@ void main() { 'isValid': true, 'state': 'entitled', 'store': 'apple', + 'storeId': 'apple', 'clientPayload': { 'format': 'toml', 'body': 'tier = "gold"', @@ -3491,6 +3530,55 @@ void main() { expect(result.iapkit!.clientPayload, isNull); }); + test('verification uses canonical official and community identities', + () async { + Map identity = {'store': 'google'}; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + if (call.method == 'initConnection') return true; + if (call.method == 'verifyPurchaseWithProvider') { + return { + 'provider': 'iapkit', + 'iapkit': { + 'isValid': true, + 'state': 'entitled', + ...identity, + } + }; + } + return null; + }); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android')); + await iap.initConnection(); + Future verify() => + iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + google: types.RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken: 'token'), + ), + ); + expect((await verify()).iapkit!.storeId, 'play'); + identity = {'store': 'unknown', 'storeId': 'community-store'}; + expect((await verify()).iapkit!.storeId, 'community-store'); + for (final invalid in [ + {'store': 'unknown'}, + {'store': 'unknown', 'storeId': 'unknown'}, + {'store': 'google', 'storeId': 'community-store'}, + {'store': 'apple', 'storeId': 'play'}, + ]) { + identity = invalid; + await expectLater( + verify(), + throwsA(isA().having( + (error) => error.code, + 'code', + types.ErrorCode.PurchaseVerificationFailed, + ))); + } + }); + test('drops a non-string IAPKit environment', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { @@ -3505,6 +3593,7 @@ void main() { 'isValid': true, 'state': 'entitled', 'store': 'amazon', + 'storeId': 'amazon', }, }; } @@ -3544,6 +3633,7 @@ void main() { 'productId': 'premium.monthly', 'state': 'grace-period', 'store': 'apple', + 'storeId': 'apple', 'environment': 'Xcode', 'clientPayload': { 'format': 'yaml', diff --git a/libraries/flutter_inapp_purchase/test/helpers_coverage_test.dart b/libraries/flutter_inapp_purchase/test/helpers_coverage_test.dart index 00d6ac77f..93fa5faed 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_coverage_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_coverage_test.dart @@ -160,6 +160,7 @@ void main() { 'id': 'android', 'productId': 'premium', 'store': 'google', + 'storeId': 'play', 'purchaseState': 'purchase_state_purchased', 'purchaseToken': 'opaque', 'transactionDate': '2026-01-02T03:04:05Z', @@ -176,6 +177,7 @@ void main() { 'id': 'pending', 'productId': 'premium', 'store': 'google', + 'storeId': 'play', 'purchaseState': 2.0, 'transactionDate': '1', }, @@ -188,6 +190,7 @@ void main() { 'id': 'default', 'productId': 'premium', 'store': 'google', + 'storeId': 'play', 'transactionDate': 1, }, platformIsAndroid: true, @@ -216,6 +219,7 @@ void main() { 'transactionId': 'apple-${entry.$1}', 'productId': 'premium', 'store': 'apple', + 'storeId': 'apple', 'purchaseState': entry.$1, 'transactionDate': 1, 'originalTransactionDateIOS': '2026-01-02T03:04:05Z', diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index 60ecd0076..48e3c4b88 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -3,6 +3,48 @@ import 'package:flutter_inapp_purchase/types.dart' as types; import 'package:flutter_test/flutter_test.dart'; void main() { + test('preserves community iOS identity in authoritative ownership lists', () { + final result = extractPurchases( + [ + { + 'id': 'opaque-txn', + 'transactionId': 'opaque-txn', + 'productId': 'premium', + 'store': 'unknown', + 'storeId': 'community-fixture', + 'quantity': 1, + 'purchaseState': 'purchased', + 'isAutoRenewing': false, + 'transactionDate': 1 + }, + ], + platformIsAndroid: false, + platformIsIOS: true, + acknowledgedAndroidPurchaseTokens: {}, + rejectMalformed: true); + expect(result.single, isA()); + expect(result.single.storeId, 'community-fixture'); + }); + + test('preserves a community store identity in an Android purchase', () { + final purchase = convertToPurchase( + { + 'id': 'fixture-token', + 'productId': 'premium', + 'transactionDate': 1, + 'store': 'unknown', + 'storeId': 'community-fixture', + 'quantity': 1, + 'purchaseState': 'purchased', + 'isAutoRenewing': false, + }, + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}); + expect(purchase.store, types.IapStore.Unknown); + expect(purchase.storeId, 'community-fixture'); + }); + TestWidgetsFlutterBinding.ensureInitialized(); group('product helpers', () { @@ -127,12 +169,75 @@ void main() { }); group('purchase helpers', () { + test('infers official identity after normalizing legacy store spelling', + () { + for (final store in ['google', 'Google', 'GOOGLE']) { + final purchase = convertToPurchase( + { + 'id': 'purchase-id', + 'productId': 'coins.pack', + 'store': store, + 'purchaseState': 'purchased', + }, + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + ); + expect(purchase.store, types.IapStore.Google); + expect(purchase.storeId, 'play'); + } + }); + + test('rejects non-string community identities without coercion', () { + for (final storeId in [ + true, + 123, + ['community-fixture'] + ]) { + expect( + () => convertToPurchase( + { + 'id': 'purchase-id', + 'productId': 'coins.pack', + 'store': 'unknown', + 'storeId': storeId, + 'purchaseState': 'purchased', + }, + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + ), + throwsFormatException, + ); + } + }); + + test('preserves strict identity validation in the iOS adapter', () { + expect( + () => convertToPurchase( + { + 'id': 'transaction-id', + 'transactionId': 'transaction-id', + 'productId': 'coins.pack', + 'store': 'apple', + 'storeId': true, + 'purchaseState': 'purchased', + }, + platformIsAndroid: false, + platformIsIOS: true, + acknowledgedAndroidPurchaseTokens: {}, + ), + throwsFormatException, + ); + }); + test('preserves canonical Android purchase fields', () { final acknowledgedTokens = {}; final purchase = convertToPurchase( { 'platform': 'android', 'store': 'google', + 'storeId': 'play', 'id': 'purchase-id', 'productId': 'coins.pack', 'transactionId': 'GPA.1234', @@ -160,6 +265,7 @@ void main() { { 'platform': 'android', 'store': 'amazon', + 'storeId': 'amazon', 'id': 'receipt-id', 'productId': 'coins.pack', 'purchaseState': 'purchased', @@ -179,6 +285,7 @@ void main() { { 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', 'id': 'transaction-id', 'productId': 'premium.monthly', 'transactionId': 'transaction-id', @@ -217,6 +324,7 @@ void main() { { 'platform': 'android', 'store': 'google', + 'storeId': 'play', 'id': 'purchase-id', 'productId': 'coins.pack', 'transactionId': 'GPA.1234', diff --git a/libraries/flutter_inapp_purchase/test/ios_methods_test.dart b/libraries/flutter_inapp_purchase/test/ios_methods_test.dart index 7079b32b9..3efd9f997 100644 --- a/libraries/flutter_inapp_purchase/test/ios_methods_test.dart +++ b/libraries/flutter_inapp_purchase/test/ios_methods_test.dart @@ -32,6 +32,7 @@ void main() { 'quantity': 1, 'isAutoRenewing': true, 'store': 'apple', + 'storeId': 'apple', }; case 'showManageSubscriptionsIOS': return >[ @@ -46,6 +47,7 @@ void main() { 'isAutoRenewing': true, 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', }, ]; case 'deepLinkToSubscriptions': @@ -65,6 +67,7 @@ void main() { { 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', 'productId': 'com.example.prod1', 'transactionId': 'txn-available', 'purchaseState': 'PURCHASED', @@ -93,6 +96,7 @@ void main() { { 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', 'productId': 'com.example.prod1', 'transactionId': 'txn-history', 'purchaseState': 'PURCHASED', @@ -126,6 +130,7 @@ void main() { 'productId': 'com.example.prod1', 'platform': 'IOS', 'store': 'apple', + 'storeId': 'apple', 'purchaseState': 'PURCHASED', 'quantity': 1, 'transactionDate': 1700000000000, @@ -139,6 +144,7 @@ void main() { 'productId': 'com.example.prod1', 'platform': 'IOS', 'store': 'apple', + 'storeId': 'apple', 'purchaseState': 'PURCHASED', 'quantity': 1, 'transactionDate': 1700000000000, @@ -172,6 +178,7 @@ void main() { 'isAutoRenewing': false, 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', }, ]; case 'getAllTransactionsIOS': @@ -187,6 +194,7 @@ void main() { 'isAutoRenewing': false, 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', }, ]; default: @@ -306,6 +314,7 @@ void main() { 'quantity': 1, 'isAutoRenewing': false, 'store': 'apple', + 'storeId': 'apple', }, {'id': 'malformed'}, ]; diff --git a/libraries/flutter_inapp_purchase/test/ios_module_methods_test.dart b/libraries/flutter_inapp_purchase/test/ios_module_methods_test.dart index 3d1e7a46e..e2d6ddcbc 100644 --- a/libraries/flutter_inapp_purchase/test/ios_module_methods_test.dart +++ b/libraries/flutter_inapp_purchase/test/ios_module_methods_test.dart @@ -55,6 +55,7 @@ void main() { 'transactionId': 't1', 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', }, ]); case 'getAppTransactionIOS': @@ -79,6 +80,7 @@ void main() { 'transactionId': 't2', 'platform': 'ios', 'store': 'apple', + 'storeId': 'apple', }, ]); } diff --git a/libraries/flutter_inapp_purchase/test/openiap_type_alignment_test.dart b/libraries/flutter_inapp_purchase/test/openiap_type_alignment_test.dart index 8462a4f99..15029a86a 100644 --- a/libraries/flutter_inapp_purchase/test/openiap_type_alignment_test.dart +++ b/libraries/flutter_inapp_purchase/test/openiap_type_alignment_test.dart @@ -52,6 +52,7 @@ void main() { purchaseToken: 'token_123', quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1700000000, ); @@ -68,6 +69,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1700000100, transactionId: 'txn_ios', environmentIOS: 'Sandbox', diff --git a/libraries/flutter_inapp_purchase/test/purchase_helpers_test.dart b/libraries/flutter_inapp_purchase/test/purchase_helpers_test.dart index 1a18ef0a5..da14c4851 100644 --- a/libraries/flutter_inapp_purchase/test/purchase_helpers_test.dart +++ b/libraries/flutter_inapp_purchase/test/purchase_helpers_test.dart @@ -14,6 +14,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: 'txn-123', ); @@ -31,6 +32,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: '', ); @@ -48,6 +50,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, transactionId: 'txn-456', ); @@ -65,6 +68,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, ); expect(purchase.transactionIdFor, isNull); @@ -81,6 +85,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, transactionId: '', ); @@ -98,6 +103,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: '', ); @@ -115,6 +121,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: 'txn', ); @@ -129,6 +136,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, ); expect( @@ -145,6 +153,7 @@ void main() { purchaseState: PurchaseState.Pending, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, ); expect( @@ -161,6 +170,7 @@ void main() { purchaseState: PurchaseState.Unknown, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, ); expect( @@ -179,6 +189,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, ); expect(purchase.iosTransactionState, isNull); @@ -192,6 +203,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: 'txn', ); @@ -209,6 +221,7 @@ void main() { purchaseState: PurchaseState.Pending, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: 'txn', ); @@ -226,6 +239,7 @@ void main() { purchaseState: PurchaseState.Unknown, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: 'txn', ); @@ -245,6 +259,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: 'txn', ); @@ -259,6 +274,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, isAcknowledgedAndroid: true, ); @@ -275,6 +291,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, ); expect(purchase.iosQuantity, isNull); @@ -289,6 +306,7 @@ void main() { quantity: 1, quantityIOS: 3, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: 'txn', ); @@ -305,6 +323,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1000.0, ); expect(purchase.iosOriginalTransactionId, isNull); @@ -318,6 +337,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1000.0, transactionId: 'txn', originalTransactionIdentifierIOS: 'orig-txn-123', diff --git a/libraries/flutter_inapp_purchase/test/refactored_types_test.dart b/libraries/flutter_inapp_purchase/test/refactored_types_test.dart index 9f0fdc4cf..5736dc6e9 100644 --- a/libraries/flutter_inapp_purchase/test/refactored_types_test.dart +++ b/libraries/flutter_inapp_purchase/test/refactored_types_test.dart @@ -35,6 +35,7 @@ void main() { purchaseToken: 'android_token', quantity: 1, store: IapStore.Google, + storeId: 'play', transactionDate: 1700000000, ); @@ -54,6 +55,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1700000300, transactionId: 'txn_ios', environmentIOS: 'Production', diff --git a/libraries/flutter_inapp_purchase/test/store_identity_test.dart b/libraries/flutter_inapp_purchase/test/store_identity_test.dart new file mode 100644 index 000000000..0190eddfb --- /dev/null +++ b/libraries/flutter_inapp_purchase/test/store_identity_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; +import 'package:platform/platform.dart'; +import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart' + show FlutterInappPurchase; +import 'package:flutter_inapp_purchase/types.dart'; +import 'package:flutter_inapp_purchase/helpers.dart' show extractPurchases; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('flutter_inapp'); + tearDown(() => TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null)); + test( + 'community completion forwards the full purchase to the Android provider', + () async { + final purchase = PurchaseAndroid.fromJson({ + 'store': 'unknown', + 'storeId': 'community-fixture', + 'id': 'opaque-id', + 'productId': 'sku', + 'quantity': 1, + 'isAutoRenewing': false, + 'purchaseState': 'purchased', + 'transactionDate': 1.0, + 'purchaseToken': 'opaque-receipt', + }); + MethodCall? completion; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'finishTransaction') completion = call; + return null; + }); + await FlutterInappPurchase.private(FakePlatform(operatingSystem: 'android')) + .finishTransaction(purchase: purchase, isConsumable: true); + expect(completion?.arguments, + {'purchase': purchase.toJson(), 'isConsumable': true}); + }); + test( + 'provider subscription status does not infer entitlement from inactive entries', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + if (call.method == 'hasActiveSubscriptions') return false; + if (call.method == 'getActiveSubscriptions') { + return [ + {'productId': 'expired', 'isActive': false} + ]; + } + return null; + }); + final iap = + FlutterInappPurchase.private(FakePlatform(operatingSystem: 'ios')); + expect(await iap.hasActiveSubscriptions(), false); + expect(calls, ['hasActiveSubscriptions']); + }); + Map payload(String store) => { + 'store': store, + 'id': 'txn', + 'productId': 'sku', + 'quantity': 1, + 'isAutoRenewing': false, + 'purchaseState': 'purchased', + 'transactionDate': 1.0, + }; + + test('authoritative Android reads preserve and validate community identities', + () { + List decode(Object? id) => extractPurchases( + [ + {...payload('unknown'), 'storeId': id} + ], + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + rejectMalformed: true, + ); + expect(decode('community-fixture').single.storeId, 'community-fixture'); + for (final id in [null, 42, 'Bad id', 'apple']) { + expect(() => decode(id), throwsFormatException); + } + }); + + test('legacy official purchases infer and round trip identity', () { + for (final entry in { + 'apple': 'apple', + 'google': 'play', + 'horizon': 'horizon', + 'amazon': 'amazon' + }.entries) { + final purchase = PurchaseAndroid.fromJson(payload(entry.key)); + expect(purchase.storeId, entry.value); + expect(PurchaseAndroid.fromJson(purchase.toJson()).storeId, entry.value); + } + }); + + test('community identity is required and survives serialization', () { + final json = {...payload('unknown'), 'storeId': 'community-fixture'}; + expect( + PurchaseAndroid.fromJson(PurchaseAndroid.fromJson(json).toJson()) + .storeId, + 'community-fixture'); + for (final id in [ + null, + '', + 'auto', + 'none', + 'unknown', + 'apple', + 'play', + 'google', + 'horizon', + 'amazon', + 'Bad id', + 'store\n', + 42 + ]) { + expect(() => PurchaseAndroid.fromJson({...json, 'storeId': id}), + throwsFormatException); + } + expect( + () => PurchaseAndroid.fromJson( + {...payload('google'), 'storeId': 'other'}), + throwsFormatException); + }); +} diff --git a/libraries/flutter_inapp_purchase/test/types_additions_test.dart b/libraries/flutter_inapp_purchase/test/types_additions_test.dart index 3d94985c5..ab451d934 100644 --- a/libraries/flutter_inapp_purchase/test/types_additions_test.dart +++ b/libraries/flutter_inapp_purchase/test/types_additions_test.dart @@ -51,6 +51,7 @@ void main() { purchaseState: PurchaseState.Purchased, quantity: 1, store: IapStore.Apple, + storeId: 'apple', transactionDate: 1700000000, transactionId: 't', expirationDateIOS: 1700005000, diff --git a/libraries/godot-iap/Example/tests/generated_nullable_enum_list_types.gd b/libraries/godot-iap/Example/tests/generated_nullable_enum_list_types.gd index 7ce3ad1b6..fcca35a27 100644 --- a/libraries/godot-iap/Example/tests/generated_nullable_enum_list_types.gd +++ b/libraries/godot-iap/Example/tests/generated_nullable_enum_list_types.gd @@ -12,6 +12,12 @@ # Keep the file named generated_nullable_enum_list_types.gd: the preload finds it by that name. const _Types = preload("generated_nullable_enum_list_types.gd") +class StoreIds: + const APPLE = "apple" + const PLAY = "play" + const HORIZON = "horizon" + const AMAZON = "amazon" + # ============================================================================ # Enums # ============================================================================ diff --git a/libraries/godot-iap/Example/tests/test_android_store.gd b/libraries/godot-iap/Example/tests/test_android_store.gd index 0b0831cb0..b02d0e8ec 100644 --- a/libraries/godot-iap/Example/tests/test_android_store.gd +++ b/libraries/godot-iap/Example/tests/test_android_store.gd @@ -21,6 +21,17 @@ func _run() -> void: _check("fire-os is an Amazon alias", AndroidStore.normalize("fire-os") == "amazon") _check("gms is a Play alias", AndroidStore.normalize("gms") == "play") _check("unknown values name no store", AndroidStore.normalize("bogus") == "") + _check("community ids need coordinates", AndroidStore.normalize("fixture") == "") + _check("community provider accepts an arbitrary id", AndroidStore.normalize("fixture", "dev.example:provider:1.0.0") == "fixture") + _check("community providers cannot replace official ids", AndroidStore.normalize("google", "dev.example:provider:1.0.0") == "") + _check("community coordinates must be fixed", AndroidStore.normalize("fixture", "dev.example:provider:+") == "") + _check("community exports link core", AndroidStore.artifact("io.github.hyochan.openiap:openiap-google:3.6.2", "fixture", "dev.example:provider:1.0.0") == "io.github.hyochan.openiap:openiap-core:3.6.2") + + var registered := GDScript.new() + registered.source_code = FileAccess.get_file_as_string("res://addons/godot-iap/android_store.gd").replace("const ALIASES := {", "const ALIASES := {\n\t\"registered-fixture\": \"fixture\",") + _check("registered alias table compiles", registered.reload() == OK) + _check("registered community aliases still require coordinates", registered.normalize("registered-fixture") == "") + _check("registered community aliases preserve the id", registered.normalize("registered-fixture", "dev.example:provider:1.0.0") == "fixture") var play := "io.github.hyochan.openiap:openiap-google:3.5.2" _check("auto exports the Play artifact", AndroidStore.artifact(play, "auto") == play) @@ -50,6 +61,14 @@ func _run() -> void: AndroidStore.horizon_app_id_meta_data('1" android:exported="true') == "" ) + var coordinates := PackedStringArray(["io.github.hyochan.openiap:openiap-google:2.0.0"]) + var provider := "community.test:billing:0.0.1" + _check("community provider dependencies include core and provider", AndroidStore.dependencies(coordinates, AndroidStore.normalize("samsung", provider), provider) == PackedStringArray(["io.github.hyochan.openiap:openiap-core:2.0.0", provider])) + _check("missing provider cannot export Play", AndroidStore.dependencies(coordinates, AndroidStore.normalize("samsung")) == PackedStringArray(["openiap.invalid:provider-selection:0"])) + _check("official store with provider cannot export Play", AndroidStore.dependencies(coordinates, AndroidStore.normalize("play", provider), provider) == PackedStringArray(["openiap.invalid:provider-selection:0"])) + _check("blank store with provider cannot export Play", AndroidStore.dependencies(coordinates, AndroidStore.normalize(" ", provider), provider) == PackedStringArray(["openiap.invalid:provider-selection:0"])) + _check("null store with provider cannot export Play", AndroidStore.dependencies(coordinates, AndroidStore.normalize(null, provider), provider) == PackedStringArray(["openiap.invalid:provider-selection:0"])) + var one := "List of devices attached\r\nAAA\tdevice\r\nCCC\tunauthorized\r\nDDD\toffline\r\n" var two := "List of devices attached\nAAA\tdevice\nBBB\tdevice\n" _check("the one ready device is selected", AndroidStore.pick_serial(one, "") == "AAA") diff --git a/libraries/godot-iap/Example/tests/test_envelope_parsing.gd b/libraries/godot-iap/Example/tests/test_envelope_parsing.gd index 737c5a640..0b3ab47fd 100644 --- a/libraries/godot-iap/Example/tests/test_envelope_parsing.gd +++ b/libraries/godot-iap/Example/tests/test_envelope_parsing.gd @@ -44,6 +44,10 @@ class FakeAndroidJsonPlugin: last_args = [] return _respond("getAvailablePurchases", "[]") + func restorePurchases() -> String: + last_method = "restorePurchases" + return _respond("restorePurchases", JSON.stringify({"success": true})) + func getAvailablePurchasesResult() -> String: last_args = [] return _respond( @@ -96,6 +100,10 @@ class FakeAndroidJsonPlugin: last_args = [props_json] return _respond("verifyPurchase", JSON.stringify({"isValid": false})) + func verifyPurchaseWithProvider(props_json: String) -> String: + last_args = [props_json] + return _respond("verifyPurchaseWithProvider", JSON.stringify({"provider": "iapkit"})) + func deepLinkToSubscriptions(options_json: String) -> String: last_args = [options_json] return _respond("deepLinkToSubscriptions", JSON.stringify({"success": true})) @@ -181,6 +189,21 @@ class FakeImmediateApplePlugin: func presentCodeRedemptionSheetIOS() -> String: return _respond("presentCodeRedemptionSheetIOS", "0") + func getPendingTransactionsIOS() -> String: + return _respond("getPendingTransactionsIOS", "0") + + func getAllTransactionsIOS() -> String: + return _respond("getAllTransactionsIOS", "0") + + func showManageSubscriptionsIOS() -> String: + return _respond("showManageSubscriptionsIOS", "0") + + func currentEntitlementIOS(_sku: String) -> String: + return _respond("currentEntitlementIOS", "0") + + func latestTransactionIOS(_sku: String) -> String: + return _respond("latestTransactionIOS", "0") + func _init() -> void: _run_suite.call_deferred() @@ -218,6 +241,7 @@ func _run_all_tests() -> void: await test_apple_async_disconnect_and_concurrency() await test_ios_restore_failure_emits_purchase_error() await test_ios_restore_waits_for_a_sign_in_sheet() + await test_android_restore_uses_provider_operation() await test_ios_sync_waits_for_a_sign_in_sheet() test_android_signal_handlers_parse_json() @@ -243,6 +267,10 @@ func _run_all_tests() -> void: test_android_billing_program_envelopes() await test_android_open_redeem_offer_code_envelope() await test_android_verify_purchase_envelope() + await test_provider_verification_errors() + await test_apple_concurrent_verification_failures() + await test_provider_query_errors() + test_apple_failed_connection_signal() test_android_is_billing_program_available_envelope() await test_android_deep_link_envelope() await test_android_storefront_invalid_envelopes() @@ -251,6 +279,7 @@ func _run_all_tests() -> void: await test_ios_immediate_payload_envelope() await test_ios_missing_request_id_envelope() await test_ios_open_redeem_offer_code_envelope() + await test_ios_transaction_lists_skip_invalid_store_identities() await test_macos_shared_api_routing() @@ -635,9 +664,30 @@ func test_android_request_purchase_success_envelope() -> void: _uninstall_fake() -## The non-iOS restore path reports failures through purchase_error. The iOS -## path used to return success = false silently, so a caller listening only to -## the signal saw Android restore failures but never iOS ones. +func test_android_restore_uses_provider_operation() -> void: + var fake = _install_android_fake() + fake.responses["getAvailablePurchasesResult"] = JSON.stringify({"success": false}) + var restored = await GodotIapPlugin.restore_purchases() + _assert_true(restored.success, "Provider restore should not be replaced by an ownership query") + _assert_equal(fake.last_method, "restorePurchases", "Android restore must reach the native provider") + + var errors: Array[Dictionary] = [] + var capture_error = func(error: Dictionary) -> void: + errors.append(error) + GodotIapPlugin.purchase_error.connect(capture_error) + fake.responses["restorePurchases"] = JSON.stringify({"success": false, "code": "network-error", "error": "Restore offline"}) + var failed = await GodotIapPlugin.restore_purchases() + _assert_false(failed.success, "Provider restore failure must be preserved") + _assert_equal(errors.size(), 1, "Restore failure should emit one error") + _assert_equal(errors[0].get("code"), "network-error", "Restore failure should preserve its code") + fake.responses["restorePurchases"] = "invalid json" + var malformed = await GodotIapPlugin.restore_purchases() + _assert_false(malformed.success, "Malformed restore responses must fail") + _assert_equal(errors[1].get("code"), "billing-response-json-parse-error", "Malformed restore should expose a parse failure") + GodotIapPlugin.purchase_error.disconnect(capture_error) + _uninstall_fake() + + func test_ios_restore_failure_emits_purchase_error() -> void: var previous_platform = GodotIapPlugin._platform var previous_plugin = GodotIapPlugin._native_plugin @@ -1022,6 +1072,32 @@ func test_android_available_purchases_envelope() -> void: _assert_equal(structured.get("success"), true, "Structured available-purchases results should preserve success") _assert_equal(structured.get("purchases", []).size(), 1, "Structured results should contain the typed purchases") + var community_purchase := { + "id": "community", "productId": "owned.sku", "store": "unknown", + "storeId": "community-fixture", "purchaseState": "purchased", + "transactionDate": 1.0, "quantity": 1, "isAutoRenewing": false, + } + fake.responses["getAvailablePurchasesResult"] = JSON.stringify({"success": true, "purchases": [community_purchase]}) + var community_owned = await GodotIapPlugin.get_available_purchases() + _assert_equal(community_owned.size(), 1, "Community ownership should decode") + _assert_equal(community_owned[0].store_id, "community-fixture", "Community ownership should preserve store identity") + for store in ["google", "amazon", "horizon"]: + var mismatched = community_purchase.duplicate() + mismatched["store"] = store + fake.responses["getAvailablePurchasesResult"] = JSON.stringify({"success": true, "purchases": [mismatched]}) + var invalid_official = await GodotIapPlugin.get_available_purchases_result() + _assert_equal(invalid_official.get("success"), false, "Contradictory official identity should reject the batch") + _assert_equal(invalid_official.get("code"), "billing-response-json-parse-error", "Official identity mismatch should use the decode error code") + fake.responses["getAvailablePurchasesResult"] = JSON.stringify({"success": true, "purchases": [community_purchase]}) + var community_restored = await GodotIapPlugin.restore_purchases() + _assert_equal(community_restored.success, true, "Community restore should succeed") + for invalid_id in [null, "", "play", "apple", "google", "horizon", "amazon", "auto", "none", "unknown", "bad id"]: + community_purchase["storeId"] = invalid_id + fake.responses["getAvailablePurchasesResult"] = JSON.stringify({"success": true, "purchases": [community_purchase]}) + var invalid_community = await GodotIapPlugin.get_available_purchases_result() + _assert_equal(invalid_community.get("success"), false, "Invalid community identity should reject the batch") + _assert_equal(invalid_community.get("code"), "billing-response-json-parse-error", "Invalid identity should use the decode error code") + fake.responses["getAvailablePurchasesResult"] = "{}" var failed = await GodotIapPlugin.get_available_purchases_result() _assert_equal(failed.get("success"), false, "Missing success must remain distinguishable from an empty store") @@ -1062,14 +1138,6 @@ func test_android_available_purchases_envelope() -> void: _assert_equal(foreign.get("success"), false, "Android results should reject foreign stores") _assert_equal(foreign.get("code"), "billing-response-json-parse-error", "Foreign stores should use the decode error code") - var restore_errors: Array[Dictionary] = [] - var capture_restore_error = func(error: Dictionary) -> void: - restore_errors.append(error) - GodotIapPlugin.purchase_error.connect(capture_restore_error) - var restored = await GodotIapPlugin.restore_purchases() - _assert_equal(restored.success, false, "Restore should fail when available purchases cannot be decoded") - _assert_equal(restore_errors.size(), 1, "Failed Android restore should emit exactly one purchase_error") - GodotIapPlugin.purchase_error.disconnect(capture_restore_error) _uninstall_fake() @@ -1332,6 +1400,62 @@ func test_ios_open_redeem_offer_code_envelope() -> void: _uninstall_fake() +func _purchase_payload(product_id: String, transaction_id: String) -> Dictionary: + return { + "id": transaction_id, + "productId": product_id, + "transactionDate": 1.0, + "transactionId": transaction_id, + "purchaseState": "purchased", + "quantity": 1, + "isAutoRenewing": false, + "platform": "ios", + "store": "apple", + } + + +func test_ios_transaction_lists_skip_invalid_store_identities() -> void: + var fake = _install_ios_fake() + var valid := _purchase_payload("valid.sku", "valid-tx") + var mismatched := _purchase_payload("mismatched.sku", "mismatched-tx") + mismatched["storeId"] = "play" + var missing := _purchase_payload("missing.sku", "missing-tx") + missing.erase("store") + var mixed := [valid, mismatched, missing] + + fake.responses["getPendingTransactionsIOS"] = JSON.stringify({ + "success": true, + "transactionsJson": JSON.stringify(mixed), + }) + fake.responses["getAllTransactionsIOS"] = JSON.stringify({ + "success": true, + "transactionsJson": JSON.stringify(mixed), + }) + fake.responses["showManageSubscriptionsIOS"] = JSON.stringify({ + "success": true, + "purchasesJson": JSON.stringify(mixed), + }) + + var pending = await GodotIapPlugin.get_pending_transactions_ios() + _assert_equal(pending.size(), 1, "Pending transactions should skip invalid store identities") + _assert_true(pending[0] is Types.PurchaseIOS, "Pending transactions should stay typed") + _assert_equal(pending[0].product_id, "valid.sku", "Valid pending transactions should be unchanged") + + var history = await GodotIapPlugin.get_all_transactions_ios() + _assert_equal(history.size(), 1, "All transactions should skip invalid store identities") + _assert_true(history[0] is Types.PurchaseIOS, "All transactions should stay typed") + _assert_equal(history[0].product_id, "valid.sku", "Valid history transactions should be unchanged") + + var changed = await GodotIapPlugin.show_manage_subscriptions_ios() + _assert_equal(changed.size(), 1, "Manage-subscriptions results should skip invalid store identities") + _assert_true(changed[0] is Types.PurchaseIOS, "Manage-subscriptions results should stay typed") + _assert_equal(changed[0].product_id, "valid.sku", "Valid manage-subscriptions results should be unchanged") + + for list in [pending, history, changed]: + _assert_false(list.has(null), "Transaction lists must never contain null") + _uninstall_fake() + + func test_macos_shared_api_routing() -> void: var fake = _install_apple_fake("macOS") var purchase_dict := { @@ -1499,3 +1623,76 @@ func _assert_true(condition: bool, message: String) -> void: func _assert_false(condition: bool, message: String) -> void: _assert_equal(condition, false, message) + + +func test_provider_verification_errors() -> void: + for platform in ["Android", "iOS"]: + var fake = _install_android_fake() if platform == "Android" else _install_ios_fake() + var errors: Array[Dictionary] = [] + var capture = func(error: Dictionary) -> void: errors.append(error) + GodotIapPlugin.purchase_error.connect(capture) + fake.responses["verifyPurchase"] = JSON.stringify({"success": false, "code": "feature-not-supported", "error": "Use the vendor backend"}) + var verification = await GodotIapPlugin.verify_purchase({"google": {"purchaseToken": "opaque"}}) + _assert_equal(verification, null, "Unsupported verification retains its nullable result") + _assert_equal(errors.size(), 1, "Unsupported verification reports one error") + _assert_equal(errors[0].get("code"), "feature-not-supported", "Verification preserves the native unsupported code") + fake.responses["verifyPurchaseWithProvider"] = JSON.stringify({"success": false, "code": "feature-not-supported", "error": "Use the vendor backend", "provider": "iapkit", "errors": [{"code": "feature-not-supported", "message": "Use the vendor backend"}]}) + var provider_result = await GodotIapPlugin.verify_purchase_with_provider({"provider": "iapkit"}) + _assert_equal(provider_result.errors[0].code, "feature-not-supported", "Managed verification preserves the native unsupported code") + GodotIapPlugin.purchase_error.disconnect(capture) + _uninstall_fake() + + +func test_provider_query_errors() -> void: + for platform in ["Android", "iOS"]: + var fake = _install_android_fake() if platform == "Android" else _install_ios_fake() + fake.responses["fetchProducts"] = JSON.stringify({"success": false, "code": "network-error", "error": "Provider cannot reach its store"}) + var result: Dictionary = await GodotIapPlugin._fetch_products_raw({"skus": ["coins.100"], "type": "in-app"}) + _assert_equal(result.get("code"), "network-error", "Catalog failures preserve their native error code") + _assert_equal(result.get("error"), "Provider cannot reach its store", "Catalog failures preserve their message") + _uninstall_fake() + + +func _collect_verification(results: Dictionary, key: String) -> void: + results[key] = await GodotIapPlugin.verify_purchase({"apple": {"sku": key}}) + + +func test_apple_concurrent_verification_failures() -> void: + var fake = _install_ios_fake() + var errors: Array[Dictionary] = [] + var results := {} + var capture = func(error: Dictionary) -> void: errors.append(error) + GodotIapPlugin.purchase_error.connect(capture) + for request_id in ["verification-first", "verification-second"]: + fake.responses["verifyPurchase"] = JSON.stringify({"status": "pending", "requestId": request_id}) + _collect_verification(results, request_id) + await process_frame + for request_id in ["verification-second", "verification-first"]: + GodotIapPlugin._on_products_fetched({ + "method": "verifyPurchase", + "requestId": request_id, + "success": false, + "code": "feature-not-supported", + "error": "Use the vendor backend", + }) + await process_frame + _assert_equal(results.size(), 2, "Both concurrent verification calls complete") + _assert_equal(errors.size(), 2, "Identical concurrent failures each emit an error") + _assert_equal(errors.map(func(error: Dictionary): return error.get("requestId")), ["verification-second", "verification-first"], "Failure events retain each operation's request identity") + GodotIapPlugin.purchase_error.disconnect(capture) + _uninstall_fake() + + +func test_apple_failed_connection_signal() -> void: + _install_ios_fake() + GodotIapPlugin._is_connected = false + var events: Array[bool] = [] + var capture = func() -> void: events.append(GodotIapPlugin.is_store_connected()) + GodotIapPlugin.connected.connect(capture) + GodotIapPlugin._on_connected(0) + _assert_false(GodotIapPlugin.is_store_connected(), "An Apple failure signal must not establish a connection") + _assert_equal(events.size(), 0, "An Apple failure signal must not emit connected") + GodotIapPlugin._on_connected(1) + _assert_equal(events, [true], "An Apple success signal establishes one connected event") + GodotIapPlugin.connected.disconnect(capture) + _uninstall_fake() diff --git a/libraries/godot-iap/Example/tests/test_godot_iap.gd b/libraries/godot-iap/Example/tests/test_godot_iap.gd index 7586562d1..33d7153e4 100644 --- a/libraries/godot-iap/Example/tests/test_godot_iap.gd +++ b/libraries/godot-iap/Example/tests/test_godot_iap.gd @@ -47,6 +47,7 @@ class FakeIOSAsyncPlugin: var request_count := 0 var last_purchase_options: Dictionary = {} var last_subscription_ids := "unset" + var disconnect_result := true func _complete(method: String, values: Dictionary) -> String: request_count += 1 @@ -56,7 +57,7 @@ class FakeIOSAsyncPlugin: "requestId": request_id, "success": true, } - payload.merge(values) + payload.merge(values, true) # Emit before returning to prove the wrapper's result cache closes the # native-call-to-signal-wait race. products_fetched.emit(payload) @@ -103,6 +104,9 @@ class FakeIOSAsyncPlugin: }]), }) + func endConnection() -> String: + return _complete("endConnection", {"success": disconnect_result}) + class FakeNoticePlugin: extends RefCounted @@ -119,6 +123,18 @@ class FakeNoticePlugin: return claim_response +class FakeFinishPlugin: + extends RefCounted + var last_finish_json := "" + var last_is_consumable := false + + # Android sends the purchase plus the flag; Apple sends one args envelope. + func finishTransaction(purchase_json: String, is_consumable: bool = false) -> String: + last_finish_json = purchase_json + last_is_consumable = is_consumable + return JSON.stringify({"success": true}) + + ## Android's JNI bridge hands a Kotlin Boolean to GDScript as int (1/0), and ## null when the call fails, so the return is deliberately untyped. class FakeAndroidNoticePlugin: @@ -142,6 +158,74 @@ class FakeOldApplePlugin: return JSON.stringify({"success": true}) +## Canned purchase envelopes returned as immediate payloads, without a requestId. +class FakePurchasePayloadPlugin: + extends RefCounted + var responses: Dictionary = {} + + func _respond(method: String) -> String: + return responses.get(method, "0") + + func requestPurchase(_params_json: String) -> String: + return _respond("requestPurchase") + + func requestPurchaseWithPayload(_payload_json: String) -> String: + return _respond("requestPurchaseWithPayload") + + func getPendingTransactionsIOS() -> String: + return _respond("getPendingTransactionsIOS") + + func getAllTransactionsIOS() -> String: + return _respond("getAllTransactionsIOS") + + func showManageSubscriptionsIOS() -> String: + return _respond("showManageSubscriptionsIOS") + + func presentCodeRedemptionSheetIOS() -> String: + return _respond("presentCodeRedemptionSheetIOS") + + func currentEntitlementIOS(_sku: String) -> String: + return _respond("currentEntitlementIOS") + + func latestTransactionIOS(_sku: String) -> String: + return _respond("latestTransactionIOS") + + +## Android init that always fails, modeling the real render-thread timing: +## the cause is readable synchronously, the signal arrives on a later frame. +class FakeFailingAndroidInitPlugin: + extends RefCounted + var last_init_error := "" + var error_json := "" + var notify: Callable = Callable() + + func initConnection() -> bool: + if notify.is_valid() and not error_json.is_empty(): + notify.call_deferred(error_json) + return false + + func initConnectionWithConfig(_config_json: String) -> bool: + return initConnection() + + func getLastInitError() -> String: + return last_init_error + + +## Android init from before the synchronous cause existed. A JNISingleton +## answers null when call() names a method the AAR does not export. +class FakeLegacyAndroidInitPlugin: + extends RefCounted + + func initConnection() -> bool: + return false + + func initConnectionWithConfig(_config_json: String) -> bool: + return false + + func getLastInitError(): + return null + + class LogCapture: extends Logger var messages: Array[String] = [] @@ -191,6 +275,9 @@ func _run_all_tests() -> void: # Connection tests (run BEFORE guard tests to avoid state leakage) await test_init_connection_mock() await test_end_connection_mock() + await test_init_connection_reports_provider_configuration_errors() + await test_init_connection_keeps_generic_text_for_other_failures() + await test_init_connection_without_init_error_getter_keeps_generic_text() # Initialization guard tests test_ready_guard_prevents_double_init() @@ -211,11 +298,17 @@ func _run_all_tests() -> void: await test_restore_purchases_mock() await test_storefront_error_contract() test_native_purchase_payload_safety() + test_community_apple_purchase_identity() + await test_ios_transaction_lists_report_invalid_store_identities() + await test_ios_single_purchase_reads_report_invalid_store_identities() + test_request_purchase_reports_invalid_store_identities() test_freed_object_options_are_ignored() test_sensitive_values_are_not_logged() # Finish transaction tests await test_finish_transaction_mock() + await test_finish_transaction_forwards_hand_built_android_purchase() + await test_finish_transaction_forwards_hand_built_ios_purchase() await test_first_purchase_notice_prints_once() await test_first_purchase_notice_needs_a_debug_console() await test_first_purchase_notice_defaults_to_the_engine_hooks() @@ -272,6 +365,103 @@ func test_init_connection_idempotent() -> void: _assert_false(result2, "Second init_connection should remain unavailable") +func _failing_android_init() -> FakeFailingAndroidInitPlugin: + var fake := FakeFailingAndroidInitPlugin.new() + GodotIapPlugin._native_plugin = fake + GodotIapPlugin._platform = "Android" + fake.notify = func(payload: String) -> void: + GodotIapPlugin._on_android_purchase_error(payload) + return fake + + +func test_init_connection_reports_provider_configuration_errors() -> void: + var fake := _failing_android_init() + var payload := JSON.stringify({ + "code": "developer-error", + "message": "No Android store provider registered. Select openiapStore and link its provider artifact.", + }) + fake.last_init_error = payload + fake.error_json = payload + var errors: Array[Dictionary] = [] + var capture_error = func(error: Dictionary) -> void: + errors.append(error) + GodotIapPlugin.purchase_error.connect(capture_error) + + var capture := LogCapture.new() + OS.add_logger(capture) + var result = await GodotIapPlugin.init_connection() + var config = Types.InitConnectionConfig.new() + var configured_result = await GodotIapPlugin.init_connection(config) + _assert_true(errors.is_empty(), "The init error signal arrives after initConnection returns") + OS.remove_logger(capture) + await process_frame + + _assert_false(result, "Failed Android init should return false") + _assert_false(configured_result, "Failed configured Android init should return false") + _assert_equal(errors.size(), 2, "Provider misconfiguration should emit purchase_error") + for error in errors: + _assert_equal(error.get("code"), "developer-error", "Provider misconfiguration should use developer-error") + var reported = capture.messages.filter(func(message: String) -> bool: return message.contains("No Android store provider registered")) + _assert_equal(reported.size(), 2, "Failed init should print the synchronously reported message") + var generic = capture.messages.filter(func(message: String) -> bool: return message.contains("Check Google Play Services")) + _assert_equal(generic, [], "A reported developer error should replace the generic Play text") + + GodotIapPlugin.purchase_error.disconnect(capture_error) + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + GodotIapPlugin._is_connected = false + + +func test_init_connection_keeps_generic_text_for_other_failures() -> void: + var fake := _failing_android_init() + var errors: Array[Dictionary] = [] + var capture_error = func(error: Dictionary) -> void: + errors.append(error) + GodotIapPlugin.purchase_error.connect(capture_error) + + var capture := LogCapture.new() + OS.add_logger(capture) + fake.last_init_error = "" + fake.error_json = "" + _assert_false(await GodotIapPlugin.init_connection(), "Silent Android init failure should return false") + fake.last_init_error = "not json" + fake.error_json = "" + _assert_false(await GodotIapPlugin.init_connection(), "Unparseable init cause should return false") + fake.last_init_error = JSON.stringify({"code": "service-error", "message": "Billing unavailable"}) + fake.error_json = fake.last_init_error + _assert_false(await GodotIapPlugin.init_connection(), "Non-developer init failure should return false") + OS.remove_logger(capture) + await process_frame + + _assert_equal(errors.size(), 1, "Only the reported failure should emit purchase_error") + var generic = capture.messages.filter(func(message: String) -> bool: return message.contains("Check Google Play Services")) + _assert_equal(generic.size(), 3, "Failures without a developer error should keep the generic Play text") + var leaked = capture.messages.filter(func(message: String) -> bool: return message.contains("Billing unavailable")) + _assert_equal(leaked, [], "Non-developer errors should not replace the init guidance") + + GodotIapPlugin.purchase_error.disconnect(capture_error) + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + GodotIapPlugin._is_connected = false + + +func test_init_connection_without_init_error_getter_keeps_generic_text() -> void: + GodotIapPlugin._native_plugin = FakeLegacyAndroidInitPlugin.new() + GodotIapPlugin._platform = "Android" + + var capture := LogCapture.new() + OS.add_logger(capture) + _assert_false(await GodotIapPlugin.init_connection(), "Legacy Android init failure should return false") + OS.remove_logger(capture) + + var generic = capture.messages.filter(func(message: String) -> bool: return message.contains("Check Google Play Services")) + _assert_equal(generic.size(), 1, "A plugin without the cause getter should keep the generic Play text") + + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + GodotIapPlugin._is_connected = false + + func test_no_duplicate_signal_connections() -> void: # After multiple _ready() calls, signals should not have duplicate connections var purchase_updated_count = GodotIapPlugin.purchase_updated.get_connections().size() @@ -424,6 +614,19 @@ func test_end_connection_mock() -> void: var result = await GodotIapPlugin.end_connection() _assert_true(result, "end_connection should return true in mock mode") + var fake := FakeIOSAsyncPlugin.new() + GodotIapPlugin._native_plugin = fake + GodotIapPlugin._platform = "iOS" + GodotIapPlugin._connect_signals_apple() + GodotIapPlugin._is_connected = true + fake.disconnect_result = false + _assert_false(await GodotIapPlugin.end_connection(), "Provider false must survive Apple disconnection") + _assert_true(GodotIapPlugin.is_store_connected(), "Unsuccessful disconnection retains connection state") + fake.disconnect_result = true + _assert_true(await GodotIapPlugin.end_connection(), "Provider true completes Apple disconnection") + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + func test_native_purchase_payload_safety() -> void: var fallback = {"purchaseJson": null, "productId": "fallback"} @@ -438,6 +641,170 @@ func test_native_purchase_payload_safety() -> void: _assert_equal(canonical.get("productId"), "canonical", "Valid purchaseJson should be decoded") +func test_community_apple_purchase_identity() -> void: + var original_platform = GodotIapPlugin._platform + GodotIapPlugin._platform = "iOS" + var payload = FakeIOSAsyncPlugin.new()._purchase("community") + payload["store"] = "unknown" + payload["storeId"] = "community-fixture" + var decoded = GodotIapPlugin._validated_purchase_batch([payload], "iOS") + _assert_true(decoded.get("success", false), "Community Apple purchases should retain the Apple shape") + _assert_equal(payload["transactionId"], "tx-community", "Provider transaction ids may be opaque") + payload["storeId"] = "apple" + _assert_false(GodotIapPlugin._validated_purchase_batch([payload], "iOS").get("success", false), "Community purchases cannot claim an official identity") + GodotIapPlugin._platform = original_platform + + +func _identity_purchase(product_id: String, transaction_id: String, store: String) -> Dictionary: + return { + "id": transaction_id, + "productId": product_id, + "transactionDate": 1.0, + "transactionId": transaction_id, + "purchaseState": "purchased", + "quantity": 1, + "isAutoRenewing": false, + "store": store, + } + + +func test_ios_transaction_lists_report_invalid_store_identities() -> void: + var fake := FakePurchasePayloadPlugin.new() + GodotIapPlugin._native_plugin = fake + GodotIapPlugin._platform = "iOS" + var valid := _identity_purchase("valid.sku", "valid-tx", "apple") + var mismatched := _identity_purchase("mismatched.sku", "mismatched-tx", "apple") + mismatched["storeId"] = "play" + var mixed := [valid, mismatched] + fake.responses["getPendingTransactionsIOS"] = JSON.stringify({"success": true, "transactionsJson": JSON.stringify(mixed)}) + fake.responses["getAllTransactionsIOS"] = JSON.stringify({"success": true, "transactionsJson": JSON.stringify(mixed)}) + fake.responses["showManageSubscriptionsIOS"] = JSON.stringify({"success": true, "purchasesJson": JSON.stringify(mixed)}) + + var capture := LogCapture.new() + OS.add_logger(capture) + var pending = await GodotIapPlugin.get_pending_transactions_ios() + var history = await GodotIapPlugin.get_all_transactions_ios() + var changed = await GodotIapPlugin.show_manage_subscriptions_ios() + OS.remove_logger(capture) + + for list in [pending, history, changed]: + _assert_equal(list.size(), 1, "Transaction lists should skip invalid store identities") + _assert_true(list[0] is Types.PurchaseIOS, "Transaction lists should stay typed") + _assert_equal(list[0].product_id, "valid.sku", "Valid transactions should be unchanged") + _assert_equal(capture.errors.size(), 3, "Each skipped transaction should log once") + for error in capture.errors: + _assert_true(error.contains("mismatched.sku"), "Skipped transactions should name the product") + _assert_true(error.contains("mismatched-tx"), "Skipped transactions should name the transaction") + + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + + +func test_ios_single_purchase_reads_report_invalid_store_identities() -> void: + var fake := FakePurchasePayloadPlugin.new() + GodotIapPlugin._native_plugin = fake + GodotIapPlugin._platform = "iOS" + var invalid := _identity_purchase("stale.sku", "stale-tx", "apple") + invalid["storeId"] = "play" + var invalid_json := JSON.stringify({"success": true, "purchaseJson": JSON.stringify(invalid)}) + fake.responses["presentCodeRedemptionSheetIOS"] = invalid_json + fake.responses["currentEntitlementIOS"] = invalid_json + fake.responses["latestTransactionIOS"] = invalid_json + + var capture := LogCapture.new() + OS.add_logger(capture) + var redeemed = await GodotIapPlugin.present_code_redemption_sheet_ios() + var entitlement = await GodotIapPlugin.current_entitlement_ios("stale.sku") + var latest = await GodotIapPlugin.latest_transaction_ios("stale.sku") + OS.remove_logger(capture) + + _assert_equal(redeemed, null, "Redemption with an invalid identity should stay null") + _assert_equal(entitlement, null, "Entitlement with an invalid identity should stay null") + _assert_equal(latest, null, "Latest transaction with an invalid identity should stay null") + _assert_equal(capture.errors.size(), 3, "Each invalid single read should log once") + for error in capture.errors: + _assert_true(error.contains("stale.sku"), "Invalid single reads should name the product") + _assert_true(error.contains("stale-tx"), "Invalid single reads should name the transaction") + + var valid := _identity_purchase("valid.sku", "valid-tx", "apple") + var valid_json := JSON.stringify({"success": true, "purchaseJson": JSON.stringify(valid)}) + fake.responses["presentCodeRedemptionSheetIOS"] = valid_json + fake.responses["currentEntitlementIOS"] = valid_json + fake.responses["latestTransactionIOS"] = valid_json + var valid_capture := LogCapture.new() + OS.add_logger(valid_capture) + var valid_redeemed = await GodotIapPlugin.present_code_redemption_sheet_ios() + var valid_entitlement = await GodotIapPlugin.current_entitlement_ios("valid.sku") + var valid_latest = await GodotIapPlugin.latest_transaction_ios("valid.sku") + OS.remove_logger(valid_capture) + for purchase in [valid_redeemed, valid_entitlement, valid_latest]: + _assert_true(purchase is Types.PurchaseIOS, "Valid single reads should stay typed") + _assert_equal(valid_capture.errors, [], "Valid single reads should not log") + + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + + +func test_request_purchase_reports_invalid_store_identities() -> void: + var fake := FakePurchasePayloadPlugin.new() + GodotIapPlugin._native_plugin = fake + + GodotIapPlugin._platform = "Android" + var android_invalid := _identity_purchase("android.sku", "android-tx", "google") + android_invalid["storeId"] = "horizon" + android_invalid["purchaseToken"] = "token-1" + android_invalid["success"] = true + fake.responses["requestPurchase"] = JSON.stringify(android_invalid) + var android_capture := LogCapture.new() + OS.add_logger(android_capture) + var android_purchase = GodotIapPlugin.request_purchase({ + "requestPurchase": {"google": {"skus": ["android.sku"]}}, + "type": "in-app", + }) + OS.remove_logger(android_capture) + _assert_equal(android_purchase, null, "Android purchases with an invalid identity should stay null") + _assert_equal(android_capture.errors.size(), 1, "Invalid Android purchases should log once") + # Indexing an empty array aborts the test and leaks the fake into later tests. + if android_capture.errors.size() == 1: + _assert_true(android_capture.errors[0].contains("android.sku"), "Invalid Android purchases should name the product") + _assert_true(android_capture.errors[0].contains("android-tx"), "Invalid Android purchases should name the transaction") + + GodotIapPlugin._platform = "iOS" + var apple_invalid := _identity_purchase("apple.sku", "apple-tx", "apple") + apple_invalid.erase("store") + apple_invalid["success"] = true + fake.responses["requestPurchaseWithPayload"] = JSON.stringify(apple_invalid) + var apple_capture := LogCapture.new() + OS.add_logger(apple_capture) + var apple_purchase = GodotIapPlugin.request_purchase({ + "requestPurchase": {"apple": {"sku": "apple.sku"}}, + "type": "in-app", + }) + OS.remove_logger(apple_capture) + _assert_equal(apple_purchase, null, "Apple purchases with an invalid identity should stay null") + _assert_equal(apple_capture.errors.size(), 1, "Invalid Apple purchases should log once") + if apple_capture.errors.size() == 1: + _assert_true(apple_capture.errors[0].contains("apple.sku"), "Invalid Apple purchases should name the product") + + GodotIapPlugin._platform = "Android" + var android_valid := _identity_purchase("android.sku", "android-tx", "google") + android_valid["purchaseToken"] = "token-1" + android_valid["success"] = true + fake.responses["requestPurchase"] = JSON.stringify(android_valid) + var valid_capture := LogCapture.new() + OS.add_logger(valid_capture) + var valid_purchase = GodotIapPlugin.request_purchase({ + "requestPurchase": {"google": {"skus": ["android.sku"]}}, + "type": "in-app", + }) + OS.remove_logger(valid_capture) + _assert_true(valid_purchase is Types.PurchaseAndroid, "Valid Android purchases should stay typed") + _assert_equal(valid_capture.errors, [], "Valid purchases should not log") + + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + + func test_freed_object_options_are_ignored() -> void: var freed = Node.new() freed.free() @@ -618,6 +985,49 @@ func test_finish_transaction_mock() -> void: _assert_true(result is Types.VoidResult, "finish_transaction should return VoidResult") +func test_finish_transaction_forwards_hand_built_android_purchase() -> void: + var fake := FakeFinishPlugin.new() + GodotIapPlugin._native_plugin = fake + GodotIapPlugin._platform = "Android" + var purchase = Types.PurchaseAndroid.new() + purchase.product_id = "coins" + purchase.purchase_token = "token-abc" + + var result = await GodotIapPlugin.finish_transaction(purchase, true) + + _assert_true(result.success, "Hand-built Android finish should succeed against the fake") + var forwarded = JSON.parse_string(fake.last_finish_json) + _assert_equal(forwarded.get("purchaseToken"), "token-abc", "Hand-built token should reach native") + _assert_equal(forwarded.get("store"), "unknown", "Hand-built store stays blank for native to stamp") + _assert_equal(forwarded.get("storeId"), "", "Hand-built store id stays blank for native to stamp") + _assert_true(fake.last_is_consumable, "Consumable flag should reach native") + + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + + +func test_finish_transaction_forwards_hand_built_ios_purchase() -> void: + var fake := FakeFinishPlugin.new() + GodotIapPlugin._native_plugin = fake + GodotIapPlugin._platform = "iOS" + var purchase = Types.PurchaseIOS.new() + purchase.product_id = "coins" + purchase.purchase_token = "jws-abc" + + var result = await GodotIapPlugin.finish_transaction(purchase, false) + + _assert_true(result.success, "Hand-built iOS finish should succeed against the fake") + var args = JSON.parse_string(fake.last_finish_json) + var forwarded = args.get("purchase", {}) + _assert_equal(forwarded.get("purchaseToken"), "jws-abc", "Hand-built token should reach native") + _assert_equal(forwarded.get("store"), "unknown", "Hand-built store stays blank for native to stamp") + _assert_equal(forwarded.get("storeId"), "", "Hand-built store id stays blank for native to stamp") + _assert_false(args.get("isConsumable", true), "Consumable flag should reach native") + + GodotIapPlugin._native_plugin = null + GodotIapPlugin._platform = "" + + # ============================================ # First-Purchase Notice # ============================================ diff --git a/libraries/godot-iap/Example/tests/test_types_only.gd b/libraries/godot-iap/Example/tests/test_types_only.gd index 445a2029c..91356dd83 100644 --- a/libraries/godot-iap/Example/tests/test_types_only.gd +++ b/libraries/godot-iap/Example/tests/test_types_only.gd @@ -36,6 +36,7 @@ func _run_all_tests() -> void: # PurchaseAndroid tests _test_purchase_android() + _test_store_identity() # PurchaseIOS tests _test_purchase_ios() @@ -234,6 +235,7 @@ func _test_purchase_android() -> void: # Test from_dict var from_dict_data = { + "store": "google", "id": "parsed_id", "productId": "parsed_product", "transactionId": "parsed_txn", @@ -832,6 +834,7 @@ func _test_purchase_android_json_round_trip() -> void: purchase.transaction_date = 1720000000000.0 purchase.purchase_token = "token-1" purchase.store = Types.IapStore.GOOGLE + purchase.store_id = "play" purchase.quantity = 2 purchase.purchase_state = Types.PurchaseState.PURCHASED purchase.is_auto_renewing = true @@ -873,10 +876,10 @@ func _test_purchase_android_json_round_trip() -> void: "pendingPurchaseUpdateAndroid token should survive the wire round trip" ) - var unknown_state = Types.PurchaseAndroid.from_dict({"productId": "p", "purchaseState": "mystery"}) + var unknown_state = Types.PurchaseAndroid.from_dict({"productId": "p", "purchaseState": "mystery", "store": "google"}) _assert_equal(unknown_state.purchase_state, Types.PurchaseState.UNKNOWN, "Unknown purchaseState strings should fall back to UNKNOWN") var unknown_store = Types.PurchaseAndroid.from_dict({"productId": "p", "store": "mystery"}) - _assert_equal(unknown_store.store, Types.IapStore.UNKNOWN, "Unknown store strings should fall back to UNKNOWN") + _assert_equal(unknown_store, null, "Unknown stores require a community identity") func _test_purchase_ios_json_round_trip() -> void: @@ -889,6 +892,7 @@ func _test_purchase_ios_json_round_trip() -> void: purchase.transaction_date = 1720000000000.0 purchase.purchase_token = "jws-token" purchase.store = Types.IapStore.APPLE + purchase.store_id = "apple" purchase.quantity = 1 purchase.purchase_state = Types.PurchaseState.PURCHASED purchase.original_transaction_identifier_ios = "orig-1" @@ -1088,3 +1092,16 @@ func _assert_equal(actual, expected, message: String) -> void: else: _total_failed += 1 print(" FAIL: %s (expected: %s, got: %s)" % [message, expected, actual]) + + +func _test_store_identity() -> void: + for store in {"apple": "apple", "google": "play", "horizon": "horizon", "amazon": "amazon"}: + var purchase = Types.PurchaseAndroid.from_dict({"store": store}) + var expected = "play" if store == "google" else store + _assert_equal(purchase.store_id, expected, "Legacy official identity should be inferred") + _assert_equal(Types.PurchaseAndroid.from_dict(purchase.to_dict()).store_id, expected, "Official identity should round trip") + var community = Types.PurchaseAndroid.from_dict({"store": "unknown", "storeId": "community-fixture"}) + _assert_equal(Types.PurchaseAndroid.from_dict(community.to_dict()).store_id, "community-fixture", "Community identity should round trip") + for id in [null, "", "auto", "none", "unknown", "apple", "play", "google", "amazon", "horizon", "Bad id", "store\n", 42]: + _assert_equal(Types.PurchaseAndroid.from_dict({"store": "unknown", "storeId": id}), null, "Malformed community identity should fail") + _assert_equal(Types.PurchaseAndroid.from_dict({"store": "google", "storeId": "other"}), null, "Conflicting official identity should fail") diff --git a/libraries/godot-iap/Makefile b/libraries/godot-iap/Makefile index 200a04f70..ac3c34bf1 100644 --- a/libraries/godot-iap/Makefile +++ b/libraries/godot-iap/Makefile @@ -116,11 +116,11 @@ ios: setup ios-build @echo "$(GREEN)✓ iOS plugin built$(NC)" # Build iOS frameworks using xcodebuild +# Select the destination so SwiftPM generators and macros keep the host SDK. ios-build: @echo "$(GREEN)Building iOS frameworks...$(NC)" @cd $(IOS_GDEXT_DIR) && xcodebuild \ -scheme GodotIap \ - -sdk iphoneos \ -destination 'generic/platform=iOS' \ -configuration Release \ CLANG_ENABLE_CODE_COVERAGE=NO \ diff --git a/libraries/godot-iap/addons/godot-iap/android/GodotIap.debug.aar b/libraries/godot-iap/addons/godot-iap/android/GodotIap.debug.aar index 04b1dd9be..72316c4c2 100644 Binary files a/libraries/godot-iap/addons/godot-iap/android/GodotIap.debug.aar and b/libraries/godot-iap/addons/godot-iap/android/GodotIap.debug.aar differ 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 292c55253..4f6fe100e 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/android_store.gd b/libraries/godot-iap/addons/godot-iap/android_store.gd index b4377d9c9..6f6e1cc24 100644 --- a/libraries/godot-iap/addons/godot-iap/android_store.gd +++ b/libraries/godot-iap/addons/godot-iap/android_store.gd @@ -4,7 +4,6 @@ extends RefCounted const STORES: PackedStringArray = ["auto", "play", "horizon", "amazon"] const ALIASES := { - "auto": "auto", "play": "play", "google": "play", "gplay": "play", @@ -18,17 +17,34 @@ const ALIASES := { "fire": "amazon", "fireos": "amazon", "fire-os": "amazon", + "auto": "auto", } ## Returns the store id, or "" when the value names no store. -static func normalize(value: Variant) -> String: +static func normalize(value: Variant, provider: String = "") -> String: if value == null: - return "auto" + return "auto" if provider.is_empty() else "" var key := str(value).strip_edges().to_lower() if key.is_empty(): - return "auto" - return ALIASES.get(key, "") + return "auto" if provider.is_empty() else "" + key = ALIASES.get(key, key) + if key in ["auto", "play", "horizon", "amazon"]: + return key if provider.is_empty() else "" + if key in ["apple", "none", "unknown"] or not valid_provider(provider): + return "" + return key if valid_store_id(key) else "" + + +static func valid_store_id(value: Variant) -> bool: + if not value is String or ALIASES.get(value, "") in ["auto", "none", "play", "horizon", "amazon"] or value in ["apple", "unknown"]: + return false + var match = RegEx.create_from_string("^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$").search(value) + return match != null and match.get_string() == value + + +static func valid_provider(provider: String) -> bool: + return not provider.begins_with("io.github.hyochan.openiap:openiap-") and RegEx.create_from_string("^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[0-9][A-Za-z0-9_.-]*$").search(provider) != null const HORIZON_APP_ID_META_DATA := "com.meta.horizon.platform.HORIZON_APP_ID" @@ -36,7 +52,9 @@ const HORIZON_APP_ID_META_DATA := "com.meta.horizon.platform.HORIZON_APP_ID" ## Export feature tag for the store an Android export linked, which get_store() ## reads at runtime. Play is the untagged default. static func store_feature(store: String) -> String: - return "openiap_store_" + store if store in ["horizon", "amazon"] else "" + if store in ["horizon", "amazon"]: + return "openiap_store_" + store + return "openiap_store_provider" if not store in ["auto", "play", ""] else "" ## The manifest entry the Horizon SDK reads the app id from, or "" unless the @@ -49,12 +67,25 @@ static func horizon_app_id_meta_data(app_id: Variant) -> String: ## Rewrites the openiap-google coordinate for the store; other coordinates pass through. -static func artifact(coordinate: String, store: String) -> String: +static func artifact(coordinate: String, store: String, provider: String = "") -> String: + if not provider.is_empty(): + return coordinate.replace(":openiap-google:", ":openiap-core:") var resolved := "play" if store == "auto" else store var suffix := "" if resolved == "play" else "-" + resolved return coordinate.replace(":openiap-google:", ":openiap-google" + suffix + ":") +static func dependencies(coordinates: PackedStringArray, store: String, provider: String = "") -> PackedStringArray: + if store.is_empty(): + return PackedStringArray(["openiap.invalid:provider-selection:0"]) + var selected := PackedStringArray() + for coordinate in coordinates: + selected.append(artifact(coordinate, store, provider)) + if not provider.is_empty(): + selected.append(provider) + return selected + + ## The serial `adb devices` selects: ANDROID_SERIAL when it is attached, ## otherwise the only device. "" when that is absent or ambiguous. static func pick_serial(devices_output: String, requested: String) -> String: 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 aafe1e58a..df3f3d99f 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/SwiftGodotRuntime.framework/SwiftGodotRuntime b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime index cbd4582e8..a3adab97b 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/bin/macos/GodotIap.framework/GodotIap b/libraries/godot-iap/addons/godot-iap/bin/macos/GodotIap.framework/GodotIap index 15650377f..0595bbffa 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/macos/GodotIap.framework/GodotIap and b/libraries/godot-iap/addons/godot-iap/bin/macos/GodotIap.framework/GodotIap differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime b/libraries/godot-iap/addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime index dac6f1b4d..0a1b5b7b0 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime and b/libraries/godot-iap/addons/godot-iap/bin/macos/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 9141cbab9..ab6cf8ee6 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -222,6 +222,9 @@ func _on_products_fetched(result: Dictionary) -> void: products_fetched.emit(result) func _on_connected(_status_code: int = 0) -> void: + if _is_apple() and _status_code != 1: + _is_connected = false + return _is_connected = true connected.emit() @@ -301,7 +304,11 @@ func init_connection(config = null) -> bool: else: _is_connected = _native_plugin.call("initConnection") if not _is_connected: - print("[GodotIap] ERROR: initConnection failed. Check Google Play Services and billing setup.") + var reported_message = _last_android_init_error_message() + if not reported_message.is_empty(): + print("[GodotIap] ERROR: initConnection failed. %s" % reported_message) + else: + print("[GodotIap] ERROR: initConnection failed. Check Google Play Services and billing setup.") else: print("[GodotIap] initConnection result: ", _is_connected) elif _is_apple(): @@ -321,6 +328,24 @@ func init_connection(config = null) -> bool: print("[GodotIap] ERROR: Cannot init connection — native plugin not available.") return false + +# The error signal queues on the render thread, so read the stored cause back. +func _last_android_init_error_message() -> String: + # has_method() doesn't work reliably with JNISingleton, so call directly. + var raw = _native_plugin.call("getLastInitError") + if not (raw is String) or raw.is_empty(): + return "" + var reported = JSON.parse_string(raw) + if not (reported is Dictionary): + return "" + var message = reported.get("message", "") + if reported.get("code", "") != "developer-error" \ + or not (message is String) \ + or message.is_empty(): + return "" + return message + + ## End the IAP connection. ## @return bool - true if disconnection was successful ## @@ -480,10 +505,9 @@ func _fetch_products_raw(request: Dictionary) -> Dictionary: var parsed = JSON.parse_string(products_json) if parsed is Array: products_array = parsed - return { - "products": products_array, - "error": signal_result.get("error", "") - } + var result: Dictionary = signal_result.duplicate() + result["products"] = products_array + return result # No native plugin return { "products": [], "subscriptions": [] } @@ -521,9 +545,15 @@ func request_purchase(props) -> Variant: return null if result.get("success", false): if _platform == "Android": - return Types.PurchaseAndroid.from_dict(_normalize_android_purchase_dict(result)) + var purchase_android = Types.PurchaseAndroid.from_dict(_normalize_android_purchase_dict(result)) + if purchase_android == null: + _log_dropped_purchase(result) + return purchase_android elif _is_apple(): - return Types.PurchaseIOS.from_dict(_normalize_purchase_dict(result)) + var purchase_apple = Types.PurchaseIOS.from_dict(_normalize_purchase_dict(result)) + if purchase_apple == null: + _log_dropped_purchase(result) + return purchase_apple # A success envelope on an unrecognized platform cannot be mapped to a # typed purchase. Report it instead of returning a bare null, which is # the silent-failure shape this contract forbids. @@ -865,42 +895,42 @@ func _print_first_purchase_notice(purchase: Dictionary, result: Dictionary) -> v print("\n".join(_FIRST_PURCHASE_NOTICE)) ## Restore completed transactions. -## Apple platforms: Performs a lightweight sync then fetches available purchases. -## Android: Simply fetches available purchases. +## Uses the selected provider's restore operation. ## @return Types.VoidResult ## ## See: https://openiap.dev/docs/apis/restore-purchases func restore_purchases() -> Variant: print("[GodotIap] restore_purchases called") - if _is_apple() and _native_plugin: + var payload: Dictionary + if not _native_plugin: + payload = {"success": false, "code": "not-prepared", "error": "Native plugin not available"} + elif _is_apple(): # AppStore.sync() can show a sign-in sheet, so restore gets the system-sheet timeout. - var payload = await _call_apple_async( + payload = await _call_apple_async( "restorePurchases", [], _apple_async_ui_timeout_seconds ) - var apple_result = Types.VoidResult.new() - apple_result.success = payload.get("success", false) - # The non-Apple path below reports a failed restore through - # purchase_error. Emit it here too, otherwise a caller that only - # listens to the signal sees Android restore failures but not Apple ones. - if not apple_result.success: - _purchase_failure( - String(payload.get("code", "service-error")), - String(payload.get("error", "Failed to restore purchases")), - payload - ) - return apple_result - - var available_result := await get_available_purchases_result() + elif _platform == "Android": + var response = JSON.parse_string(_native_plugin.call("restorePurchases")) + if response is Dictionary: + payload = response + else: + payload = { + "success": false, + "code": "billing-response-json-parse-error", + "error": "Failed to parse the Android restore response", + } + else: + payload = {"success": false, "code": "feature-not-supported", "error": "Unsupported platform"} var result = Types.VoidResult.new() - result.success = available_result.get("success", false) + result.success = payload.get("success", false) if not result.success: _purchase_failure( - String(available_result.get("code", "service-error")), - String(available_result.get("error", "Failed to restore purchases")), - available_result + String(payload.get("code", "service-error")), + String(payload.get("error", "Failed to restore purchases")), + payload ) return result @@ -948,10 +978,14 @@ func get_available_purchases_result(options = null) -> Dictionary: var purchases: Array = [] for purchase_dict in raw_purchases: + var purchase if _platform == "Android": - purchases.append(Types.PurchaseAndroid.from_dict(_normalize_android_purchase_dict(purchase_dict))) + purchase = Types.PurchaseAndroid.from_dict(_normalize_android_purchase_dict(purchase_dict)) elif _is_apple(): - purchases.append(Types.PurchaseIOS.from_dict(_normalize_purchase_dict(purchase_dict))) + purchase = Types.PurchaseIOS.from_dict(_normalize_purchase_dict(purchase_dict)) + if purchase == null: + return {"success": false, "code": "billing-response-json-parse-error", "error": "Invalid purchase data"} + purchases.append(purchase) return { "success": true, @@ -977,6 +1011,15 @@ func _normalize_android_purchase_dict(purchase_dict: Dictionary) -> Dictionary: return normalized +# from_dict returns null when the store identity is rejected. Name the payload +# so a skipped transaction can be traced back to the store. +func _log_dropped_purchase(purchase_dict: Dictionary) -> void: + push_error( + "[GodotIap] Dropping purchase with invalid store identity (productId=%s, transactionId=%s)." + % [purchase_dict.get("productId", "?"), purchase_dict.get("transactionId", purchase_dict.get("id", "?"))] + ) + + func _as_dictionary(value) -> Dictionary: if is_instance_valid(value) and value.has_method("to_dict"): return value.to_dict() @@ -1076,9 +1119,11 @@ func _is_valid_purchase_dictionary(value) -> bool: or String(value.get(required_string)).is_empty(): return false var store := String(value.get("store")) - if _is_apple() and store != "apple": + if _is_apple() and store not in ["apple", "unknown"]: + return false + if _platform == "Android" and store not in ["google", "amazon", "horizon"] and store != "unknown": return false - if _platform == "Android" and store not in ["google", "amazon", "horizon"]: + if not Types.IAP_STORE_FROM_STRING.has(store) or Types.resolve_store_id(Types.IAP_STORE_FROM_STRING[store], value.get("storeId")) == null: return false var transaction_date = value.get("transactionDate") if not transaction_date is float and not transaction_date is int: @@ -1362,12 +1407,15 @@ func verify_purchase(props) -> Variant: var decoded = JSON.parse_string(payload_json) if decoded is Dictionary: return Types.VerifyPurchaseResultIOS.from_dict(decoded) + _purchase_failure(payload.get("code", "purchase-verification-failed"), payload.get("error", "Verification failed"), payload) return null var result = _verify_purchase_raw(props_dict) if result.get("success", false) or result.get("isValid", false): if _platform == "Android": return Types.VerifyPurchaseResultAndroid.from_dict(result) + if result.has("code") and not result.get("success", false): + _purchase_failure(result.code, result.get("error", "Verification failed"), result) return null ## Internal: Verify purchase with raw Dictionary func _verify_purchase_raw(props: Dictionary) -> Dictionary: @@ -1399,7 +1447,7 @@ func verify_purchase_with_provider(props) -> Variant: "provider": props_dict.get("provider", "iapkit"), "errors": [ { - "code": "purchase-verification-failed", + "code": payload.get("code", "purchase-verification-failed") if payload is Dictionary else "purchase-verification-failed", "message": payload.get("error", "Verification failed") if payload is Dictionary else "Verification failed", }, ], @@ -1467,7 +1515,11 @@ func get_pending_transactions_ios() -> Array: if transactions is Array: for tx in transactions: if tx is Dictionary: - purchases.append(Types.PurchaseIOS.from_dict(tx)) + var purchase = Types.PurchaseIOS.from_dict(tx) + if purchase == null: + _log_dropped_purchase(tx) + continue + purchases.append(purchase) return purchases ## Get all transactions including finished consumables (iOS only). @@ -1485,7 +1537,11 @@ func get_all_transactions_ios() -> Array: if transactions is Array: for tx in transactions: if tx is Dictionary: - purchases.append(Types.PurchaseIOS.from_dict(tx)) + var purchase = Types.PurchaseIOS.from_dict(tx) + if purchase == null: + _log_dropped_purchase(tx) + continue + purchases.append(purchase) return purchases ## Present the code redemption sheet (iOS only). @@ -1507,9 +1563,12 @@ func present_code_redemption_sheet_ios() -> Variant: return null var purchase_json = payload.get("purchaseJson", "") if purchase_json is String and not purchase_json.is_empty(): - var purchase = JSON.parse_string(purchase_json) - if purchase is Dictionary: - return Types.PurchaseIOS.from_dict(purchase) + var parsed = JSON.parse_string(purchase_json) + if parsed is Dictionary: + var purchase = Types.PurchaseIOS.from_dict(parsed) + if purchase == null: + _log_dropped_purchase(parsed) + return purchase return null ## Show manage subscriptions UI (iOS only). @@ -1528,7 +1587,11 @@ func show_manage_subscriptions_ios() -> Array: if parsed is Array: for p in parsed: if p is Dictionary: - purchases.append(Types.PurchaseIOS.from_dict(p)) + var purchase = Types.PurchaseIOS.from_dict(p) + if purchase == null: + _log_dropped_purchase(p) + continue + purchases.append(purchase) return purchases ## Begin refund request (iOS only). @@ -1559,7 +1622,10 @@ func current_entitlement_ios(sku: String) -> Variant: if purchase_json != "null": var parsed = JSON.parse_string(purchase_json) if parsed is Dictionary: - return Types.PurchaseIOS.from_dict(parsed) + var purchase = Types.PurchaseIOS.from_dict(parsed) + if purchase == null: + _log_dropped_purchase(parsed) + return purchase return null ## Get the latest transaction for a product (iOS only). @@ -1575,7 +1641,10 @@ func latest_transaction_ios(sku: String) -> Variant: if purchase_json != "null": var parsed = JSON.parse_string(purchase_json) if parsed is Dictionary: - return Types.PurchaseIOS.from_dict(parsed) + var purchase = Types.PurchaseIOS.from_dict(parsed) + if purchase == null: + _log_dropped_purchase(parsed) + return purchase return null ## Get app transaction (iOS 16+). @@ -2204,6 +2273,8 @@ func is_stub_mode() -> bool: ## Returns Types.IapStore enum value func get_store() -> Variant: if _platform == "Android": + if _has_feature.call("openiap_store_provider"): + return Types.IapStore.UNKNOWN # Every store is an Android build; the export tags the one it linked. if _has_feature.call(AndroidStore.store_feature("horizon")): return Types.IapStore.HORIZON @@ -2211,6 +2282,8 @@ func get_store() -> Variant: return Types.IapStore.AMAZON return Types.IapStore.GOOGLE elif _is_apple(): + if _native_plugin and _native_plugin.has_method("getStoreId"): + return Types.IapStore.APPLE if _native_plugin.call("getStoreId") == "apple" else Types.IapStore.UNKNOWN return Types.IapStore.APPLE return Types.IapStore.UNKNOWN diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd b/libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd index 4f552c026..16e2e3aa9 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap_plugin.gd @@ -32,6 +32,7 @@ class GodotIapExportPlugin extends EditorExportPlugin: const ANDROID_GDAP_PATH = "res://addons/godot-iap/android/GodotIap.gdap" const AndroidStore = preload("res://addons/godot-iap/android_store.gd") const ANDROID_STORE_OPTION = "openiap/android_store" + const ANDROID_PROVIDER_OPTION = "openiap/android_provider" const HORIZON_APP_ID_OPTION = "openiap/horizon_app_id" const IOS_FRAMEWORKS: Array[String] = [ "res://addons/godot-iap/bin/ios/GodotIap.framework", @@ -91,10 +92,16 @@ class GodotIapExportPlugin extends EditorExportPlugin: "option": { "name": ANDROID_STORE_OPTION, "type": TYPE_STRING, - "hint": PROPERTY_HINT_ENUM, + "hint": PROPERTY_HINT_ENUM_SUGGESTION, "hint_string": ",".join(AndroidStore.STORES), }, "default_value": "auto", + }, { + "option": { + "name": ANDROID_PROVIDER_OPTION, + "type": TYPE_STRING, + }, + "default_value": "", }, { "option": { "name": HORIZON_APP_ID_OPTION, @@ -123,10 +130,11 @@ class GodotIapExportPlugin extends EditorExportPlugin: ## The store this Android export links, or "" when the option names none. func _android_store(debug: bool) -> String: var option = get_option(ANDROID_STORE_OPTION) - var key := "%s|%s" % [debug, option] + var provider := str(get_option(ANDROID_PROVIDER_OPTION)).strip_edges() + var key := "%s|%s|%s" % [debug, option, provider] if _android_stores.has(key): return _android_stores[key] - var store := AndroidStore.normalize(option) + var store := AndroidStore.normalize(option, provider) if store == "auto": var resolution := AndroidStore.resolve_auto(debug, _adb_path() if debug else "") store = resolution.store @@ -144,15 +152,10 @@ class GodotIapExportPlugin extends EditorExportPlugin: func _get_android_dependencies(platform: EditorExportPlatform, debug: bool) -> PackedStringArray: var store := _android_store(debug) + var provider := str(get_option(ANDROID_PROVIDER_OPTION)).strip_edges() if store.is_empty(): - # Godot's export API cannot abort here, so fall back to Play (the - # untagged default) instead of shipping the AAR without OpenIAP classes. - push_error("[GodotIap] %s must be one of: %s; falling back to Play" % [ANDROID_STORE_OPTION, ", ".join(AndroidStore.STORES)]) - store = "play" - var dependencies := PackedStringArray() - for dependency in _read_android_remote_dependencies(): - dependencies.append(AndroidStore.artifact(dependency, store)) - return dependencies + push_error("[GodotIap] Select a valid official store, or a community id with fixed group:artifact:version provider coordinates") + return AndroidStore.dependencies(_read_android_remote_dependencies(), store, provider) # The editor's SDK setting first, then the Gradle resolver's fallbacks. func _adb_path() -> String: diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 2c89d96fc..21f3459ef 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -12,6 +12,23 @@ # Keep the file named types.gd: the preload finds it by that name. const _Types = preload("types.gd") +class StoreIds: + const APPLE = "apple" + const PLAY = "play" + const HORIZON = "horizon" + const AMAZON = "amazon" + +static func resolve_store_id(store: Variant, value: Variant) -> Variant: + var official: Variant = {IapStore.APPLE: "apple", IapStore.GOOGLE: "play", IapStore.HORIZON: "horizon", IapStore.AMAZON: "amazon"}.get(store) + var id: Variant = value if value != null else official + if not id is String: + return null + if official != null: + return id if id == official else null + var pattern = RegEx.new() + pattern.compile("^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") + return id if pattern.search(id) != null and pattern.search(id).get_string() == id and not id in ["auto", "none", "unknown", "apple", "play", "google", "horizon", "amazon"] else null + # ============================================================================ # Enums # ============================================================================ @@ -208,6 +225,7 @@ enum IapPlatform { ANDROID = 1, } +## Frozen legacy store discriminator. Use storeId for extensible store identity. enum IapStore { UNKNOWN = 0, APPLE = 1, @@ -364,6 +382,7 @@ class ActiveSubscription: var product_id: String = "" var is_active: bool = false var expiration_date_ios: Variant = null + ## Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. var auto_renewing_android: Variant = null var environment_ios: Variant = null var days_until_expiration_ios: Variant = null @@ -2254,12 +2273,16 @@ class PurchaseAndroid: var purchase_token: Variant = null ## Store where purchase was made var store: _Types.IapStore = IapStore.UNKNOWN + ## Stable store id: apple, play, horizon, amazon, or a community provider id. + var store_id: String = "" var quantity: int = 0 var purchase_state: _Types.PurchaseState = PurchaseState.UNKNOWN + ## Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. var is_auto_renewing: bool = false var current_plan_id: Variant = null var data_android: Variant = null var signature_android: Variant = null + ## Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. var auto_renewing_android: Variant = null var is_acknowledged_android: Variant = null var package_name_android: Variant = null @@ -2343,6 +2366,10 @@ class PurchaseAndroid: obj.user_id_amazon = data["userIdAmazon"] if data.has("userMarketplaceAmazon") and data["userMarketplaceAmazon"] != null: obj.user_marketplace_amazon = data["userMarketplaceAmazon"] + var store_id = _Types.resolve_store_id(obj.store, data.get("storeId")) + if store_id == null: + return null + obj.store_id = store_id return obj func to_dict() -> Dictionary: @@ -2359,6 +2386,7 @@ class PurchaseAndroid: dict["store"] = IAP_STORE_VALUES[store] else: dict["store"] = store + dict["storeId"] = store_id dict["quantity"] = quantity if PURCHASE_STATE_VALUES.has(purchase_state): dict["purchaseState"] = PURCHASE_STATE_VALUES[purchase_state] @@ -2479,8 +2507,11 @@ class PurchaseIOS: var purchase_token: Variant = null ## Store where purchase was made var store: _Types.IapStore = IapStore.UNKNOWN + ## Stable store id: apple, play, horizon, amazon, or a community provider id. + var store_id: String = "" var quantity: int = 0 var purchase_state: _Types.PurchaseState = PurchaseState.UNKNOWN + ## Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. var is_auto_renewing: bool = false var current_plan_id: Variant = null var transaction_id: String = "" @@ -2648,6 +2679,10 @@ class PurchaseIOS: obj.advanced_commerce_info_ios = AdvancedCommerceInfoIOS.from_dict(data["advancedCommerceInfoIOS"]) else: obj.advanced_commerce_info_ios = data["advancedCommerceInfoIOS"] + var store_id = _Types.resolve_store_id(obj.store, data.get("storeId")) + if store_id == null: + return null + obj.store_id = store_id return obj func to_dict() -> Dictionary: @@ -2662,6 +2697,7 @@ class PurchaseIOS: dict["store"] = IAP_STORE_VALUES[store] else: dict["store"] = store + dict["storeId"] = store_id dict["quantity"] = quantity if PURCHASE_STATE_VALUES.has(purchase_state): dict["purchaseState"] = PURCHASE_STATE_VALUES[purchase_state] @@ -2973,6 +3009,8 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: _Types.IapStore = IapStore.UNKNOWN + ## Stable store id: apple, play, horizon, amazon, or a community provider id. + var store_id: String = "" ## Available in OpenIAP 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. Deliberately String, not an enum: the value space belongs to IAPKit and the stores behind it, and Apple's App Store Server alone also names `Xcode` and `LocalTesting`. SDKs must forward this value opaquely. Never reject a verification because the environment is unrecognised — that fails a purchase the store already confirmed. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. @@ -3011,6 +3049,10 @@ class RequestVerifyPurchaseWithIapkitResult: if data.has("clientPayload") and data["clientPayload"] != null: if data["clientPayload"] is Dictionary: obj.client_payload = IapkitProductClientPayload.from_dict_or_null(data["clientPayload"]) + var store_id = _Types.resolve_store_id(obj.store, data.get("storeId")) + if store_id == null: + return null + obj.store_id = store_id return obj func to_dict() -> Dictionary: @@ -3019,6 +3061,7 @@ class RequestVerifyPurchaseWithIapkitResult: dict["store"] = IAP_STORE_VALUES[store] else: dict["store"] = store + dict["storeId"] = store_id if environment != null: dict["environment"] = environment dict["isValid"] = is_valid @@ -3034,6 +3077,55 @@ class RequestVerifyPurchaseWithIapkitResult: dict["clientPayload"] = client_payload return dict +## Store-provider contract shared by the Apple and Android native bindings. coreVersion names the native contract build; clientProtocolVersion names the Client Protocol build. Capabilities use the conformance provider profile ids. +class StoreProviderDescriptor: + var store_id: String = "" + var platform: _Types.IapPlatform + var core_version: String = "" + var client_protocol_version: String = "" + var capabilities: Array[String] = [] + + static func from_dict(data: Dictionary, report_errors: bool = true) -> _Types.StoreProviderDescriptor: + if not data.has("platform") or not ((data["platform"] is String and IAP_PLATFORM_FROM_STRING.has(data["platform"])) or (data["platform"] is int and IAP_PLATFORM_VALUES.has(data["platform"]))): + if report_errors: + push_error("Invalid StoreProviderDescriptor.platform enum value") + return null + var obj = StoreProviderDescriptor.new() + if data.has("storeId") and data["storeId"] != null: + obj.store_id = data["storeId"] + if data.has("platform") and data["platform"] != null: + var enum_str = data["platform"] + if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str): + obj.platform = IAP_PLATFORM_FROM_STRING[enum_str] + elif enum_str is int and IAP_PLATFORM_VALUES.has(enum_str): + obj.platform = enum_str + else: + obj.platform = enum_str + if data.has("coreVersion") and data["coreVersion"] != null: + obj.core_version = data["coreVersion"] + if data.has("clientProtocolVersion") and data["clientProtocolVersion"] != null: + obj.client_protocol_version = data["clientProtocolVersion"] + if data.has("capabilities") and data["capabilities"] != null: + if data["capabilities"] is Array: + var arr: Array[String] = [] + for item in data["capabilities"]: + if item is String: + arr.append(str(item)) + obj.capabilities = arr + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["storeId"] = store_id + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + dict["coreVersion"] = core_version + dict["clientProtocolVersion"] = client_protocol_version + dict["capabilities"] = capabilities + return dict + class SubscriptionCommitmentInfoIOS: var display_price: String = "" var period: _Types.SubscriptionPeriodValueIOS @@ -4307,6 +4399,7 @@ class PurchaseInput: var store: Variant = null var quantity: int = 0 var purchase_state: _Types.PurchaseState = PurchaseState.UNKNOWN + ## Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. var is_auto_renewing: bool = false static func from_dict(data: Dictionary) -> _Types.PurchaseInput: @@ -4658,7 +4751,7 @@ class RequestPurchaseProps: dict["type"] = PRODUCT_QUERY_TYPE_VALUES.get(type, type) return dict -## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Uses the selected Apple-platform provider (App Store by default) - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) class RequestPurchasePropsByPlatforms: ## Apple-specific purchase parameters var apple: _Types.RequestPurchaseIosProps @@ -4967,7 +5060,7 @@ class RequestSubscriptionIosProps: dict["advancedCommerceData"] = advanced_commerce_data return dict -## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Uses the selected Apple-platform provider (App Store by default) - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) class RequestSubscriptionPropsByPlatforms: ## Apple-specific subscription parameters var apple: _Types.RequestSubscriptionIosProps @@ -6421,7 +6514,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Initiate a purchase or subscription flow; rely on events for final state. See: https://openiap.dev/docs/apis/request-purchase + ## Initiate a purchase or subscription flow; rely on events for final state. Providers emit one canonical purchase-error event before returning or throwing a request failure. See: https://openiap.dev/docs/apis/request-purchase class requestPurchaseField: const name = "requestPurchase" const snake_name = "request_purchase" @@ -6991,7 +7084,7 @@ static func init_connection_args(config: Variant = null) -> Dictionary: static func end_connection_args() -> Dictionary: return {} -## Initiate a purchase or subscription flow; rely on events for final state. See: https://openiap.dev/docs/apis/request-purchase +## Initiate a purchase or subscription flow; rely on events for final state. Providers emit one canonical purchase-error event before returning or throwing a request failure. See: https://openiap.dev/docs/apis/request-purchase static func request_purchase_args(params: _Types.RequestPurchaseProps) -> Dictionary: var args = {} if params != null: diff --git a/libraries/godot-iap/android/build.gradle.kts b/libraries/godot-iap/android/build.gradle.kts index 6e8b926ad..2689667c6 100644 --- a/libraries/godot-iap/android/build.gradle.kts +++ b/libraries/godot-iap/android/build.gradle.kts @@ -95,12 +95,11 @@ kotlin { } dependencies { - // In monorepo: use local packages/google source if available - val localGoogleProject = findProject(":openiap") - if (localGoogleProject != null) { - implementation(project(":openiap")) + val localCoreProject = findProject(":openiap-core") + if (localCoreProject != null) { + implementation(project(":openiap-core")) } else { - implementation("io.github.hyochan.openiap:openiap-google:$openiapGoogleVersion") + implementation("io.github.hyochan.openiap:openiap-core:$openiapGoogleVersion") } // Godot Android library diff --git a/libraries/godot-iap/android/settings.gradle.kts b/libraries/godot-iap/android/settings.gradle.kts index 2c0df53eb..619395272 100644 --- a/libraries/godot-iap/android/settings.gradle.kts +++ b/libraries/godot-iap/android/settings.gradle.kts @@ -52,6 +52,10 @@ val localOpenIapProject = file("../../../packages/google/openiap") if (localOpenIapProject.exists()) { include(":openiap") project(":openiap").projectDir = localOpenIapProject + include(":openiap-core") + project(":openiap-core").projectDir = file("../../../packages/google/core") + include(":openiap-conformance") + project(":openiap-conformance").projectDir = file("../../../packages/conformance/android") } dependencyResolutionManagement { 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 b976a5b20..a8d8efef6 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 @@ -1,7 +1,8 @@ package dev.hyo.godotiap import dev.hyo.openiap.* -import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProvider +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.helpers.OpenIapFirstPurchaseNotice import dev.hyo.openiap.store.OpenIapStore import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener @@ -33,8 +34,9 @@ import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalL */ class GodotIap(godot: Godot) : GodotPlugin(godot) { - private lateinit var openIap: OpenIapModule + private lateinit var openIap: OpenIapProtocol private lateinit var store: OpenIapStore + private var lastInitError: OpenIapError.ProviderConfiguration? = null private val connectionLifecycle = GodotIapConnectionLifecycle() private val isInitialized: Boolean get() = connectionLifecycle.isConnected @@ -50,7 +52,9 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } // Listeners + private val restoreGate = GodotIapRestoreGate() private val purchaseUpdateListener = OpenIapPurchaseUpdateListener { purchase -> + if (!restoreGate.shouldEmit()) return@OpenIapPurchaseUpdateListener GodotIapLog.debug("Purchase updated: ${purchase.productId}") val sanitized = GodotIapHelper.sanitizeDictionary(purchase.toJson()) emitSignal("purchase_updated", JSONObject(sanitized).toString()) @@ -100,6 +104,8 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { @UsedByGodot fun initConnectionWithConfig(configJson: String): Boolean { + // A malformed config returns below, so clear before parsing. + lastInitError = null val config = try { InitConnectionConfig.fromJson( GodotIapHelper.jsonObjectToMap(JSONObject(configJson)) @@ -111,8 +117,14 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { return initConnectionInternal(config) } + // Render-thread signals arrive after initConnection returns; the wrapper log reads this. + @UsedByGodot + fun getLastInitError(): String = GodotIapHelper.lastInitErrorJson(lastInitError) + private fun initConnectionInternal(config: InitConnectionConfig?): Boolean { GodotIapLog.debug("initConnection called") + // A stale cause must not leak into the next attempt's log. + lastInitError = null val activity = activity ?: run { GodotIapLog.failure("initConnection", Exception("Activity is null")) @@ -133,7 +145,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { }, connect = { runBlocking { - val candidateModule = OpenIapModule(activity) + val candidateModule = OpenIapProvider.create(activity) val candidateStore = OpenIapStore(candidateModule) attachListeners(candidateModule, candidateStore) try { @@ -158,6 +170,11 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { ) } catch (error: Exception) { GodotIapLog.failure("initConnection", error) + // A missing or incompatible provider is a build mistake, not a store outage. + (error as? OpenIapError.ProviderConfiguration)?.let { + lastInitError = it + purchaseErrorListener.onPurchaseError(it) + } GodotIapConnectionLifecycle.InitResult(false, false) } @@ -191,7 +208,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { return result.success } - private fun attachListeners(module: OpenIapModule, targetStore: OpenIapStore): Unit { + private fun attachListeners(module: OpenIapProtocol, targetStore: OpenIapStore): Unit { targetStore.addPurchaseUpdateListener(purchaseUpdateListener) targetStore.addPurchaseErrorListener(purchaseErrorListener) targetStore.addUserChoiceBillingListener(userChoiceBillingListener) @@ -199,7 +216,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { module.addSubscriptionBillingIssueListener(subscriptionBillingIssueListener) } - private fun detachListeners(module: OpenIapModule, targetStore: OpenIapStore): Unit { + private fun detachListeners(module: OpenIapProtocol, targetStore: OpenIapStore): Unit { targetStore.removePurchaseUpdateListener(purchaseUpdateListener) targetStore.removePurchaseErrorListener(purchaseErrorListener) targetStore.removeUserChoiceBillingListener(userChoiceBillingListener) @@ -281,6 +298,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } catch (e: Exception) { GodotIapLog.failure("fetchProducts", e) JSONObject().apply { + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) put("products", JSONArray()) }.toString() @@ -361,6 +379,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("requestPurchase", e) return JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -382,6 +401,10 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { }.toString() } + /** Store id of the linked provider, or null when it cannot be read. */ + private fun connectedProviderStoreId(): String? = + runCatching { activity?.let { OpenIapProvider.factory(it).storeId } }.getOrNull() + @UsedByGodot fun finishTransaction(purchaseJson: String, isConsumable: Boolean): String { GodotIapLog.payload("finishTransaction", mapOf("purchaseJson" to purchaseJson, "isConsumable" to isConsumable)) @@ -396,7 +419,9 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { return runBlocking { try { val requestedPurchase = PurchaseAndroid.fromJson( - GodotIapHelper.jsonObjectToMap(JSONObject(purchaseJson)) + GodotIapHelper.withProviderStoreIdentity( + GodotIapHelper.jsonObjectToMap(JSONObject(purchaseJson)), + ) { connectedProviderStoreId() } ) val purchase = if (!requestedPurchase.purchaseToken.isNullOrBlank()) { requestedPurchase @@ -433,6 +458,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("finishTransaction", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -451,35 +477,16 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.debug("restorePurchases called") if (!isInitialized) { - return JSONObject().apply { - put("success", false) - put("error", "Not initialized") - }.toString() + return restoreNotInitialized() } return runBlocking { - try { - // getAvailablePurchases effectively restores purchases - val purchases = store.getAvailablePurchases(null) - - // Emit each purchase - for (purchase in purchases) { - val sanitized = GodotIapHelper.sanitizeDictionary(purchase.toJson()) - emitSignal("purchase_updated", JSONObject(sanitized).toString()) - } - - GodotIapLog.result("restorePurchases", "count=${purchases.size}") - JSONObject().apply { - put("success", true) - put("count", purchases.size) - }.toString() - } catch (e: Exception) { - GodotIapLog.failure("restorePurchases", e) - JSONObject().apply { - put("success", false) - put("error", e.message) - }.toString() - } + runSilentRestore( + gate = restoreGate, + suppressListener = shouldSuppressRestoreListeners(connectedProviderStoreId()), + restore = { store.restorePurchases() }, + countAvailable = { store.getAvailablePurchases(null).size }, + ) } } @@ -768,6 +775,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("acknowledgePurchaseAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -796,6 +804,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("consumePurchaseAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -831,6 +840,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { JSONObject().apply { put("success", false) put("isAvailable", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -869,6 +879,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("getBillingChoiceInfoAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -921,6 +932,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("launchExternalLinkAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -940,7 +952,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { return try { val redemptionStore = - if (isInitialized) store else OpenIapStore(OpenIapModule(activity)) + if (isInitialized) store else OpenIapStore(OpenIapProvider.create(activity)) val launched = runBlocking { redemptionStore.openRedeemOfferCode(activity) } GodotIapLog.result("openRedeemOfferCodeAndroid", launched) JSONObject().apply { @@ -951,6 +963,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("openRedeemOfferCodeAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -973,6 +986,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } catch (e: Exception) { JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -1008,6 +1022,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("createBillingProgramReportingDetailsAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -1059,6 +1074,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("showBillingProgramInformationDialogAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -1099,6 +1115,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("showInAppMessagesAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -1132,6 +1149,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("getStorefrontAndroid", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -1168,6 +1186,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("deepLinkToSubscriptions", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) put("error", e.message) }.toString() } @@ -1228,6 +1247,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { GodotIapLog.failure("verifyPurchase", e) JSONObject().apply { put("success", false) + put("code", (e as? OpenIapError)?.code ?: ErrorCode.PurchaseVerificationFailed.toJson()) put("error", e.message) }.toString() } @@ -1241,7 +1261,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { mapOf("hasIapkit" to propsJson.contains("\"iapkit\"")) ) - fun errorResponse(message: String): String { + fun errorResponse(message: String, code: String = ErrorCode.PurchaseVerificationFailed.toJson()): String { return JSONObject().apply { put("success", false) put("provider", PurchaseVerificationProvider.Iapkit.toJson()) @@ -1249,7 +1269,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { "errors", JSONArray().apply { put(JSONObject().apply { - put("code", ErrorCode.PurchaseVerificationFailed.toJson()) + put("code", code) put("message", message) }) } @@ -1258,7 +1278,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } if (!isInitialized) { - return errorResponse("Not initialized") + return errorResponse("Not initialized", ErrorCode.NotPrepared.toJson()) } return runBlocking { @@ -1302,7 +1322,7 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { response } catch (e: Exception) { GodotIapLog.failure("verifyPurchaseWithProvider", e) - errorResponse(e.message ?: "Verification failed") + errorResponse(e.message ?: "Verification failed", (e as? OpenIapError)?.code ?: ErrorCode.PurchaseVerificationFailed.toJson()) } } } 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 10c32ce91..c1ef56aa0 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 @@ -2,7 +2,10 @@ package dev.hyo.godotiap import dev.hyo.openiap.AndroidSubscriptionOfferInput import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid +import dev.hyo.openiap.IapStore +import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.ProductQueryType +import dev.hyo.openiap.StoreIds import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid import dev.hyo.openiap.SubscriptionReplacementModeAndroid import org.json.JSONArray @@ -238,6 +241,45 @@ internal object GodotIapHelper { } } + /** Synchronous init failure for the wrapper log; the signal arrives a frame late. */ + fun lastInitErrorJson(error: OpenIapError.ProviderConfiguration?): String { + if (error == null) return "" + return JSONObject().apply { + put("code", error.code) + put("message", error.message) + }.toString() + } + + /** + * Hand-built finish inputs carry blank store identity; stamp the connected + * provider's so decoding reaches the purchase token. + */ + fun withProviderStoreIdentity( + purchase: Map, + providerStoreId: () -> String?, + ): Map { + val storeIdValue = purchase["storeId"] + if (storeIdValue is String && storeIdValue.isNotBlank()) return purchase + if (storeIdValue != null && storeIdValue !is String) return purchase + if (IapStore.fromJson((purchase["store"] as? String) ?: "") != IapStore.Unknown) { + // An official store with a blank id decodes once the blank key is gone. + if (storeIdValue !is String) return purchase + return purchase - "storeId" + } + val storeId = providerStoreId()?.takeIf { it.isNotBlank() } ?: return purchase + return purchase + mapOf( + "store" to legacyStoreFor(storeId), + "storeId" to storeId, + ) + } + + private fun legacyStoreFor(providerStoreId: String): String = when (providerStoreId) { + StoreIds.Play -> IapStore.Google.toJson() + StoreIds.Horizon -> IapStore.Horizon.toJson() + StoreIds.Amazon -> IapStore.Amazon.toJson() + else -> IapStore.Unknown.toJson() + } + /** * Request purchase parameters data class. * Matches ExpoIapHelper.RequestPurchaseParams structure. diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapRestoreGate.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapRestoreGate.kt new file mode 100644 index 000000000..0f26e4115 --- /dev/null +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIapRestoreGate.kt @@ -0,0 +1,76 @@ +package dev.hyo.godotiap + +import dev.hyo.openiap.ErrorCode +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.StoreIds +import org.json.JSONObject + +/** + * Drops listener-driven purchase emissions while a restore runs. The Horizon + * provider notifies purchase listeners inside restorePurchases, and restore + * stays silent, so those notifications must not signal. The listener fires on + * another thread, hence the volatile. + */ +internal class GodotIapRestoreGate { + @Volatile + private var restoring = false + + fun shouldEmit(): Boolean = !restoring + + suspend fun whileRestoring(block: suspend () -> T): T { + restoring = true + try { + return block() + } finally { + restoring = false + } + } +} + +/** Restore before connecting reports not-prepared, like the purchase queries. */ +internal fun restoreNotInitialized(): String = + JSONObject().apply { + put("success", false) + put("code", "not-prepared") + put("error", "IAP connection is not initialized") + }.toString() + +/** + * Only the Horizon restore notifies purchase listeners, so only it runs + * behind the gate. Unknown or unreadable ids mean no suppression. + */ +internal fun shouldSuppressRestoreListeners(providerStoreId: String?): Boolean = + providerStoreId == StoreIds.Horizon + +/** + * Runs the provider restore, then counts the available purchases. Emits + * nothing; the result JSON carries success and count. + */ +internal suspend fun runSilentRestore( + gate: GodotIapRestoreGate, + suppressListener: Boolean, + restore: suspend () -> Unit, + countAvailable: suspend () -> Int, + onFailure: (Exception) -> Unit = { GodotIapLog.failure("restorePurchases", it) }, +): String { + return try { + if (suppressListener) { + gate.whileRestoring(restore) + } else { + restore() + } + val count = countAvailable() + GodotIapLog.result("restorePurchases", "count=$count") + JSONObject().apply { + put("success", true) + put("count", count) + }.toString() + } catch (error: Exception) { + onFailure(error) + JSONObject().apply { + put("success", false) + put("code", (error as? OpenIapError)?.code ?: ErrorCode.ServiceError.toJson()) + put("error", error.message) + }.toString() + } +} diff --git a/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapHelperTest.kt b/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapHelperTest.kt index b4e3bd1c1..1b2c13018 100644 --- a/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapHelperTest.kt +++ b/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapHelperTest.kt @@ -2,9 +2,13 @@ package dev.hyo.godotiap import dev.hyo.openiap.BillingProgramAndroid import dev.hyo.openiap.InAppMessageCategoryAndroid +import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.ProductQueryType +import dev.hyo.openiap.PurchaseAndroid import dev.hyo.openiap.SubscriptionReplacementModeAndroid +import org.json.JSONObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Test @@ -180,4 +184,139 @@ class GodotIapHelperTest { assertEquals(2, multipleOffers.subscriptionOffers.size) } + @Test + fun `last init error keeps the provider code and message`() { + val reported = JSONObject( + GodotIapHelper.lastInitErrorJson( + OpenIapError.ProviderConfiguration( + "No Android store provider registered. Select openiapStore and link its provider artifact.", + ), + ), + ) + + assertEquals("developer-error", reported.getString("code")) + assertEquals( + "No Android store provider registered. Select openiapStore and link its provider artifact.", + reported.getString("message"), + ) + } + + @Test + fun `missing last init error reads as empty`() { + assertEquals("", GodotIapHelper.lastInitErrorJson(null)) + } + + @Test + fun `missing store identity fills from the connected official provider`() { + mapOf("play" to "google", "horizon" to "horizon", "amazon" to "amazon").forEach { (storeId, store) -> + val filled = GodotIapHelper.withProviderStoreIdentity( + mapOf("productId" to "coins", "purchaseToken" to "token"), + ) { storeId } + assertEquals(store, filled["store"]) + assertEquals(storeId, filled["storeId"]) + assertEquals("token", PurchaseAndroid.fromJson(filled).purchaseToken) + } + } + + @Test + fun `missing store identity fills community providers as unknown`() { + val filled = GodotIapHelper.withProviderStoreIdentity( + mapOf("productId" to "coins", "purchaseToken" to "token"), + ) { "community-fixture" } + assertEquals("unknown", filled["store"]) + assertEquals("community-fixture", filled["storeId"]) + assertEquals("token", PurchaseAndroid.fromJson(filled).purchaseToken) + } + + @Test + fun `named store identities pass through untouched`() { + var lookups = 0 + val provider = { lookups += 1; "play" } + listOf( + mapOf("store" to "google", "storeId" to "play"), + mapOf("store" to "google", "storeId" to "horizon"), + mapOf("store" to "google"), + mapOf("storeId" to "play"), + ).forEach { input -> + assertSame(input, GodotIapHelper.withProviderStoreIdentity(input, provider)) + } + assertEquals(0, lookups) + assertThrows(IllegalArgumentException::class.java) { + PurchaseAndroid.fromJson(mapOf("store" to "google", "storeId" to "horizon")) + } + } + + @Test + fun `unreadable provider leaves identity-less payloads untouched`() { + listOf( + mapOf("productId" to "coins"), + mapOf("productId" to "coins", "store" to "unknown", "storeId" to ""), + ).forEach { input -> + assertSame(input, GodotIapHelper.withProviderStoreIdentity(input) { null }) + } + } + + @Test + fun `hand-built blank store identity fills from the connected provider`() { + val blankIdentities = listOf( + mapOf("store" to "unknown", "storeId" to ""), + mapOf("store" to "unknown"), + mapOf("storeId" to ""), + mapOf("store" to "", "storeId" to " "), + mapOf("store" to "UNKNOWN", "storeId" to ""), + ) + mapOf("play" to "google", "horizon" to "horizon", "amazon" to "amazon").forEach { (storeId, store) -> + blankIdentities.forEach { identity -> + val input = identity + mapOf("productId" to "coins", "purchaseToken" to "token") + val filled = GodotIapHelper.withProviderStoreIdentity(input) { storeId } + assertEquals(store, filled["store"]) + assertEquals(storeId, filled["storeId"]) + assertEquals("token", PurchaseAndroid.fromJson(filled).purchaseToken) + } + } + } + + @Test + fun `hand-built blank store identity fills community providers as unknown`() { + val filled = GodotIapHelper.withProviderStoreIdentity( + mapOf("productId" to "coins", "purchaseToken" to "token", "store" to "unknown", "storeId" to ""), + ) { "community-fixture" } + assertEquals("unknown", filled["store"]) + assertEquals("community-fixture", filled["storeId"]) + assertEquals("token", PurchaseAndroid.fromJson(filled).purchaseToken) + } + + @Test + fun `blank storeId with official store drops the key for decoder inference`() { + listOf("", " ").forEach { blank -> + val filled = GodotIapHelper.withProviderStoreIdentity( + mapOf("productId" to "coins", "purchaseToken" to "token", "store" to "google", "storeId" to blank), + ) { "play" } + assertEquals(false, filled.containsKey("storeId")) + assertEquals("google", filled["store"]) + val decoded = PurchaseAndroid.fromJson(filled) + assertEquals("play", decoded.storeId) + assertEquals("token", decoded.purchaseToken) + } + } + + @Test + fun `explicit storeId passes through untouched and keeps explicit community identity`() { + var lookups = 0 + val provider = { lookups += 1; "play" } + listOf( + mapOf("store" to "unknown", "storeId" to "community-fixture", "purchaseToken" to "token"), + mapOf("store" to "google", "storeId" to "play", "purchaseToken" to "token"), + mapOf("storeId" to "community-fixture"), + ).forEach { input -> + assertSame(input, GodotIapHelper.withProviderStoreIdentity(input, provider)) + } + assertEquals(0, lookups) + val community = PurchaseAndroid.fromJson( + mapOf("store" to "unknown", "storeId" to "community-fixture", "purchaseToken" to "token"), + ) + assertEquals("community-fixture", community.storeId) + assertEquals("token", community.purchaseToken) + } + } diff --git a/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapRestoreGateTest.kt b/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapRestoreGateTest.kt new file mode 100644 index 000000000..4c0b953df --- /dev/null +++ b/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapRestoreGateTest.kt @@ -0,0 +1,181 @@ +package dev.hyo.godotiap + +import dev.hyo.openiap.OpenIapError +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GodotIapRestoreGateTest { + /** Horizon notifies every purchase listener synchronously per owned purchase. */ + private fun fakeHorizonRestore(emit: (String) -> Unit, owned: List) { + owned.forEach(emit) + } + + @Test + fun `restore drops listener emissions and reopens the gate`() { + runBlocking { + val gate = GodotIapRestoreGate() + val signalled = mutableListOf() + val listenerEmit = { purchase: String -> + if (gate.shouldEmit()) signalled.add(purchase) + } + gate.whileRestoring { + assertFalse(gate.shouldEmit()) + fakeHorizonRestore(listenerEmit, listOf("a", "b")) + } + assertTrue(signalled.isEmpty()) + assertTrue(gate.shouldEmit()) + listenerEmit("c") + assertEquals(listOf("c"), signalled) + } + } + + @Test + fun `gate reopens after a throwing restore`() { + runBlocking { + val gate = GodotIapRestoreGate() + val result = runCatching { + gate.whileRestoring { throw IllegalStateException("store down") } + } + assertTrue(result.isFailure) + assertTrue(gate.shouldEmit()) + } + } + + @Test + fun `silent restore returns count and emits nothing`() { + runBlocking { + val gate = GodotIapRestoreGate() + val signalled = mutableListOf() + val listenerEmit = { purchase: String -> + if (gate.shouldEmit()) signalled.add(purchase) + } + val result = JSONObject( + runSilentRestore( + gate = gate, + suppressListener = true, + restore = { fakeHorizonRestore(listenerEmit, listOf("a", "b")) }, + countAvailable = { 2 }, + onFailure = { throw AssertionError("success must not report failure") }, + ), + ) + assertTrue(result.getBoolean("success")) + assertEquals(2, result.getInt("count")) + assertTrue(signalled.isEmpty()) + assertTrue(gate.shouldEmit()) + } + } + + @Test + fun `unsuppressed restore passes listener emissions through`() { + runBlocking { + val gate = GodotIapRestoreGate() + val signalled = mutableListOf() + val listenerEmit = { purchase: String -> + if (gate.shouldEmit()) signalled.add(purchase) + } + val result = JSONObject( + runSilentRestore( + gate = gate, + suppressListener = false, + restore = { listenerEmit("a") }, + countAvailable = { 1 }, + onFailure = { throw AssertionError("success must not report failure") }, + ), + ) + assertTrue(result.getBoolean("success")) + assertEquals(1, result.getInt("count")) + assertEquals(listOf("a"), signalled) + assertTrue(gate.shouldEmit()) + } + } + + @Test + fun `throwing provider restore reports the error code and reopens the gate`() { + runBlocking { + val gate = GodotIapRestoreGate() + val failures = mutableListOf() + val result = JSONObject( + runSilentRestore( + gate = gate, + suppressListener = true, + restore = { throw OpenIapError.UserCancelled() }, + countAvailable = { throw AssertionError("a failing restore skips the query") }, + onFailure = failures::add, + ), + ) + assertFalse(result.getBoolean("success")) + assertEquals("user-cancelled", result.getString("code")) + assertEquals(1, failures.size) + assertTrue(gate.shouldEmit()) + } + } + + @Test + fun `unsuppressed failing restore keeps the error code and the emission`() { + runBlocking { + val gate = GodotIapRestoreGate() + val signalled = mutableListOf() + val failures = mutableListOf() + val result = JSONObject( + runSilentRestore( + gate = gate, + suppressListener = false, + restore = { + if (gate.shouldEmit()) signalled.add("a") + throw OpenIapError.UserCancelled() + }, + countAvailable = { throw AssertionError("a failing restore skips the query") }, + onFailure = failures::add, + ), + ) + assertFalse(result.getBoolean("success")) + assertEquals("user-cancelled", result.getString("code")) + assertEquals(listOf("a"), signalled) + assertEquals(1, failures.size) + assertTrue(gate.shouldEmit()) + } + } + + @Test + fun `failing purchase query reports service error and reopens the gate`() { + runBlocking { + val gate = GodotIapRestoreGate() + val failures = mutableListOf() + var restored = false + val result = JSONObject( + runSilentRestore( + gate = gate, + suppressListener = true, + restore = { restored = true }, + countAvailable = { throw IllegalStateException("store down") }, + onFailure = failures::add, + ), + ) + assertTrue(restored) + assertFalse(result.getBoolean("success")) + assertEquals("service-error", result.getString("code")) + assertEquals(1, failures.size) + assertTrue(gate.shouldEmit()) + } + } + + @Test + fun `restore before connecting keeps the not-prepared code`() { + val result = JSONObject(restoreNotInitialized()) + assertFalse(result.getBoolean("success")) + assertEquals("not-prepared", result.getString("code")) + assertEquals("IAP connection is not initialized", result.getString("error")) + } + + @Test + fun `suppression follows the connected provider`() { + assertTrue(shouldSuppressRestoreListeners("horizon")) + listOf("play", "amazon", "community-fixture", "", " ", null).forEach { storeId -> + assertFalse("storeId=$storeId", shouldSuppressRestoreListeners(storeId)) + } + } +} diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index 2ce7ab9e6..2dd3a8e96 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -114,7 +114,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitInitConnectionFailure( generation: generation, requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -146,21 +147,29 @@ public class GodotIap: RefCounted, @unchecked Sendable { public func endConnection() -> String { GodotIapLog.payload("endConnection", payload: nil) let requestId = UUID().uuidString - enqueueLifecycleOperation { [weak self] generation in + enqueueLifecycleOperation(invalidateConnection: false) { [weak self] generation in guard let self else { return } do { - let _ = try await self.openIap.endConnection() + let disconnected = try await self.openIap.endConnection() + var resultGeneration = generation + if disconnected { + let lifecycle = self.advanceConnectionGeneration() + self.removeListeners(lifecycle.listeners) + resultGeneration = lifecycle.generation + } await self.emitEndConnectionSuccess( - generation: generation, - requestId: requestId + generation: resultGeneration, + requestId: requestId, + success: disconnected ) - GodotIapLog.result("endConnection", value: true) + GodotIapLog.result("endConnection", value: disconnected) } catch { GodotIapLog.failure("endConnection", error: error) await self.emitEndConnectionFailure( generation: generation, requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -199,7 +208,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self?.emitAsyncFailure( method: "fetchProducts", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" @@ -239,11 +249,11 @@ public class GodotIap: RefCounted, @unchecked Sendable { } catch { GodotIapLog.failure("fetchProducts", error: error) - await self?.emitProductsFetched( - success: false, - error: error.localizedDescription, + await self?.emitAsyncFailure( method: "fetchProducts", - requestId: requestId + requestId: requestId, + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -276,47 +286,13 @@ public class GodotIap: RefCounted, @unchecked Sendable { "error": error.localizedDescription ]) } - let productId = purchaseProductId(from: purchaseProps) - Task { [weak self] in do { - let result = try await self?.openIap.requestPurchase(purchaseProps) - - switch result { - case .purchase(let purchase): - if purchase == nil { - await self?.emitPurchaseError( - code: ErrorCode.userCancelled.rawValue, - message: "Purchase was cancelled", - productId: productId - ) - } - case .purchases(let purchases): - if purchases?.isEmpty ?? true { - await self?.emitPurchaseError( - code: ErrorCode.userCancelled.rawValue, - message: "Purchase was cancelled", - productId: productId - ) - } - case .none: - await self?.emitPurchaseError( - code: ErrorCode.userCancelled.rawValue, - message: "Purchase was cancelled", - productId: productId - ) - } - } catch let error as PurchaseError { - // OpenIAP requestPurchase emits its canonical error exactly once - // through purchaseErrorListener before throwing. - GodotIapLog.failure("requestPurchaseWithPayload", error: error) + // Providers may complete through listeners without returning a purchase. + _ = try await self?.openIap.requestPurchase(purchaseProps) } catch { + // Providers own purchase-error delivery after dispatch. GodotIapLog.failure("requestPurchaseWithPayload", error: error) - await self?.emitPurchaseError( - code: ErrorCode.purchaseError.rawValue, - message: error.localizedDescription, - productId: productId - ) } } @@ -345,7 +321,10 @@ public class GodotIap: RefCounted, @unchecked Sendable { let isConsumable = args["isConsumable"] as? Bool ?? false // Use OpenIapSerialization to create PurchaseInput - let purchaseInput = try OpenIapSerialization.purchaseInput(from: purchaseDict) + let identified = GodotIapHelper.withProviderStoreIdentity(purchaseDict) { + self.openIap.storeId + } + let purchaseInput = try OpenIapSerialization.purchaseInput(from: identified) try await self.openIap.finishTransaction(purchase: purchaseInput, isConsumable: isConsumable) GodotIapLog.result("finishTransaction", value: true) @@ -361,7 +340,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "finishTransaction", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -383,12 +363,12 @@ public class GodotIap: RefCounted, @unchecked Sendable { Task { [weak self] in guard let self = self else { return } do { - try await self.openIap.restorePurchases() - - // Get available purchases after restore - let purchases = try await self.openIap.getAvailablePurchases( - PurchaseOptions(onlyIncludeActiveItemsIOS: true) - ) + let purchases = try await GodotIapHelper.withCompletionErrors { + try await self.openIap.restorePurchases() + return try await self.openIap.getAvailablePurchases( + PurchaseOptions(onlyIncludeActiveItemsIOS: true) + ) + } for purchase in purchases { await self.emitPurchaseUpdated(purchase: purchase) @@ -403,11 +383,11 @@ public class GodotIap: RefCounted, @unchecked Sendable { self.productsFetched.emit(dict) } } catch { - await self.emitPurchaseError(code: ErrorCode.syncError.rawValue, message: error.localizedDescription) await self.emitAsyncFailure( method: "restorePurchases", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error, fallback: .syncError) ) } } @@ -467,7 +447,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "getAvailablePurchases", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -527,7 +508,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "getActiveSubscriptions", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -573,7 +555,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "hasActiveSubscriptions", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -602,7 +585,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "getStorefront", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -610,6 +594,9 @@ public class GodotIap: RefCounted, @unchecked Sendable { return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } + @Callable + public func getStoreId() -> String { openIap.storeId ?? "" } + // MARK: - iOS Specific Methods @Callable @@ -636,6 +623,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("syncIOS") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -669,6 +657,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("clearTransactionIOS") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -718,6 +707,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("getPendingTransactionsIOS") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -767,6 +757,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("getAllTransactionsIOS") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -806,6 +797,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("presentCodeRedemptionSheetIOS") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -855,6 +847,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("showManageSubscriptionsIOS") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -888,6 +881,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("beginRefundRequestIOS") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -940,7 +934,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "currentEntitlementIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -991,7 +986,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "latestTransactionIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1042,7 +1038,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "getAppTransactionIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1085,7 +1082,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "subscriptionStatusIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1115,7 +1113,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "isEligibleForIntroOfferIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1166,7 +1165,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "getPromotedProductIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1196,7 +1196,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "canPresentExternalPurchaseNoticeIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1230,7 +1231,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "presentExternalPurchaseNoticeSheetIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1263,7 +1265,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "presentExternalPurchaseLinkIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1304,6 +1307,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("deepLinkToSubscriptions") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -1338,7 +1342,9 @@ public class GodotIap: RefCounted, @unchecked Sendable { // Create VerifyPurchaseProps from JSON using OpenIapSerialization let props = try OpenIapSerialization.verifyPurchaseProps(from: json) - let result = try await self.openIap.verifyPurchase(props) + let result = try await GodotIapHelper.withCompletionErrors { + try await self.openIap.verifyPurchase(props) + } await MainActor.run { [self] in let dict = VariantDictionary() @@ -1359,6 +1365,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("verifyPurchase") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error, fallback: .purchaseVerificationFailed)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -1390,7 +1397,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "getReceiptDataIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1420,7 +1428,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "isTransactionVerifiedIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1450,7 +1459,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "getTransactionJwsIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1503,6 +1513,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["method"] = Variant("verifyPurchaseWithProvider") dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) + dict["code"] = Variant(GodotIapHelper.errorCode(error, fallback: .purchaseVerificationFailed)) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } @@ -1541,7 +1552,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "isEligibleForExternalPurchaseCustomLinkIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1588,7 +1600,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "getExternalPurchaseCustomLinkTokenIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1633,7 +1646,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { await self.emitAsyncFailure( method: "showExternalPurchaseCustomLinkNoticeIOS", requestId: requestId, - message: error.localizedDescription + message: error.localizedDescription, + code: GodotIapHelper.errorCode(error) ) } } @@ -1704,6 +1718,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { } private func enqueueLifecycleOperation( + invalidateConnection: Bool = true, _ operation: @escaping (UInt64) async -> Void ) { withLifecycleLock { @@ -1713,9 +1728,15 @@ public class GodotIap: RefCounted, @unchecked Sendable { await predecessor.value } guard let self else { return } - let lifecycle = self.advanceConnectionGeneration() - self.removeListeners(lifecycle.listeners) - await operation(lifecycle.generation) + let generation: UInt64 + if invalidateConnection { + let lifecycle = self.advanceConnectionGeneration() + self.removeListeners(lifecycle.listeners) + generation = lifecycle.generation + } else { + generation = self.withLifecycleLock { self.lifecycleGeneration } + } + await operation(generation) } lifecycleTail = task } @@ -1750,10 +1771,17 @@ public class GodotIap: RefCounted, @unchecked Sendable { dedupeTransactionIOS: dedupeTransactionIOS ) purchaseErrorSubscription = openIap.purchaseErrorListener { [weak self] error in - Task { @MainActor [weak self] in - guard let self, - self.isConnectionActive(generation: generation) else { return } - self.emitPurchaseError(error) + let forward: @Sendable () -> Void = { [weak self] in + Task { @MainActor [weak self] in + guard let self, + self.isConnectionActive(generation: generation) else { return } + self.emitPurchaseError(error) + } + } + if let owner = GodotIapHelper.completionErrorOwner { + owner.receive(forward) + } else { + forward() } } promotedProductSubscription = openIap.promotedProductListenerIOS { [weak self] productId in @@ -1835,27 +1863,31 @@ public class GodotIap: RefCounted, @unchecked Sendable { private func emitInitConnectionFailure( generation: UInt64, requestId: String, - message: String + message: String, + code: String = ErrorCode.serviceError.rawValue ) { guard isConnectionGenerationCurrent(generation) else { return } connected.emit(0) emitAsyncFailure( method: "initConnection", requestId: requestId, - message: message + message: message, + code: code ) } @MainActor private func emitEndConnectionSuccess( generation: UInt64, - requestId: String + requestId: String, + success: Bool ) { guard isConnectionGenerationCurrent(generation) else { return } - disconnected.emit(0) + if success { disconnected.emit(0) } let dict = asyncResultDictionary( method: "endConnection", - requestId: requestId + requestId: requestId, + success: success ) productsFetched.emit(dict) } @@ -1864,25 +1896,18 @@ public class GodotIap: RefCounted, @unchecked Sendable { private func emitEndConnectionFailure( generation: UInt64, requestId: String, - message: String + message: String, + code: String = ErrorCode.serviceError.rawValue ) { guard isConnectionGenerationCurrent(generation) else { return } emitAsyncFailure( method: "endConnection", requestId: requestId, - message: message + message: message, + code: code ) } - private func purchaseProductId(from props: RequestPurchaseProps) -> String? { - switch props.request { - case .purchase(let platforms): - return platforms.apple?.sku - case .subscription(let platforms): - return platforms.apple?.sku - } - } - @MainActor private func emitPurchaseUpdated(purchase: Purchase) { // Extract all required fields from the underlying type @@ -1917,6 +1942,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["transactionDate"] = Variant(transactionDate) dict["purchaseState"] = Variant(purchase.purchaseState.rawValue) dict["store"] = Variant(store) + dict["storeId"] = Variant(purchase.storeId) dict["quantity"] = Variant(quantity) dict["isAutoRenewing"] = Variant(isAutoRenewing) @@ -1933,6 +1959,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { let dict = VariantDictionary() dict["productId"] = Variant(purchase.productId) dict["purchaseState"] = Variant(purchase.purchaseState.rawValue) + dict["storeId"] = Variant(purchase.storeId) switch purchase { case .purchaseIos(let p): dict["id"] = Variant(p.id) @@ -2017,7 +2044,6 @@ public class GodotIap: RefCounted, @unchecked Sendable { private func emitProductsFetched( success: Bool, products: [[String: Any]]? = nil, - error: String? = nil, method: String, requestId: String ) { @@ -2033,10 +2059,6 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["productsJson"] = Variant(jsonString) } - if let error = error { - dict["error"] = Variant(error) - } - self.productsFetched.emit(dict) } diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift index 09d13fb8b..290762af2 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift @@ -34,6 +34,105 @@ import OpenIAP /// Provides parsing functions for request parameters and sanitization utilities. /// Mirrors ExpoIapHelper for consistency across platforms. enum GodotIapHelper { + @TaskLocal static var completionErrorOwner: CompletionErrorOwner? + + // Only errors represented by a failed completion suppress their listener callbacks. + final class CompletionErrorOwner: @unchecked Sendable { + private let lock = NSLock() + private var completionSucceeded: Bool? + private var callbacks: [@Sendable () -> Void] = [] + + func receive(_ callback: @escaping @Sendable () -> Void) { + lock.lock() + guard let succeeded = completionSucceeded else { + callbacks.append(callback) + lock.unlock() + return + } + lock.unlock() + if succeeded { callback() } + } + + func complete(succeeded: Bool) { + lock.lock() + completionSucceeded = succeeded + let pending = callbacks + callbacks.removeAll() + lock.unlock() + if succeeded { + for callback in pending { callback() } + } + } + } + + static func withCompletionErrors(_ operation: () async throws -> T) async rethrows -> T { + let owner = CompletionErrorOwner() + do { + let result = try await $completionErrorOwner.withValue(owner) { + try await operation() + } + owner.complete(succeeded: true) + return result + } catch { + owner.complete(succeeded: false) + throw error + } + } + + static func errorCode(_ error: Error, fallback: ErrorCode = .serviceError) -> String { + (error as? PurchaseError)?.code.rawValue ?? fallback.rawValue + } + + // Hand-built finish inputs carry blank store identity; stamp the connected + // provider's so decoding reaches the purchase token. + static func withProviderStoreIdentity( + _ purchase: [String: Any], + providerStoreId: () -> String? + ) -> [String: Any] { + let storeIdValue = purchase["storeId"] + if let rawId = storeIdValue as? String, !isBlank(rawId) { + return purchase + } + if storeIdValue != nil, !(storeIdValue is NSNull), !(storeIdValue is String) { + return purchase + } + let storeRaw = trimmed((purchase["store"] as? String) ?? "") + switch storeRaw { + case IapStore.apple.rawValue, IapStore.google.rawValue, + IapStore.horizon.rawValue, IapStore.amazon.rawValue: + // An official store with a blank id decodes once the blank key is gone. + guard storeIdValue is String else { return purchase } + var dropped = purchase + dropped.removeValue(forKey: "storeId") + return dropped + default: + guard let storeId = providerStoreId(), !isBlank(storeId) else { + return purchase + } + var stamped = purchase + stamped["store"] = legacyStore(for: storeId) + stamped["storeId"] = storeId + return stamped + } + } + + private static func trimmed(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func isBlank(_ value: String) -> Bool { + trimmed(value).isEmpty + } + + private static func legacyStore(for providerStoreId: String) -> String { + switch providerStoreId { + case StoreIds.Apple: return IapStore.apple.rawValue + case StoreIds.Play: return IapStore.google.rawValue + case StoreIds.Horizon: return IapStore.horizon.rawValue + case StoreIds.Amazon: return IapStore.amazon.rawValue + default: return IapStore.unknown.rawValue + } + } // MARK: - Sanitization diff --git a/libraries/godot-iap/ios-gdextension/Tests/GodotIapTests/GodotIapHelperTests.swift b/libraries/godot-iap/ios-gdextension/Tests/GodotIapTests/GodotIapHelperTests.swift index d69026dad..95c368edf 100644 --- a/libraries/godot-iap/ios-gdextension/Tests/GodotIapTests/GodotIapHelperTests.swift +++ b/libraries/godot-iap/ios-gdextension/Tests/GodotIapTests/GodotIapHelperTests.swift @@ -1,7 +1,78 @@ import XCTest +import OpenIAP @testable import GodotIap final class GodotIapHelperTests: XCTestCase { + func testSuccessfulInvalidVerificationPreservesItsErrorNotification() async { + let forwarded = expectation(description: "sku-not-found remains observable") + let result = await GodotIapHelper.withCompletionErrors { + GodotIapHelper.completionErrorOwner?.receive { forwarded.fulfill() } + return false + } + XCTAssertFalse(result) + await fulfillment(of: [forwarded], timeout: 1) + XCTAssertNil(GodotIapHelper.completionErrorOwner) + } + + func testFailedCompletionSuppressesEarlyAndLateInheritedCallbacks() async { + let owner = GodotIapHelper.CompletionErrorOwner() + let unexpected = expectation(description: "completion already carries the error") + unexpected.isInverted = true + let late = GodotIapHelper.$completionErrorOwner.withValue(owner) { + owner.receive { unexpected.fulfill() } + return Task { + await Task.yield() + return await Task { @MainActor in GodotIapHelper.completionErrorOwner }.value + } + } + owner.complete(succeeded: false) + let inherited = await late.value + XCTAssertTrue(inherited === owner) + inherited?.receive { unexpected.fulfill() } + await fulfillment(of: [unexpected], timeout: 0.05) + XCTAssertNil(GodotIapHelper.completionErrorOwner) + } + + func testConcurrentOperationOwnersDoNotSuppressEachOther() async { + let failed = GodotIapHelper.CompletionErrorOwner() + let successful = GodotIapHelper.CompletionErrorOwner() + let forwarded = expectation(description: "successful operation notification") + let suppressed = expectation(description: "failed operation notification") + suppressed.isInverted = true + failed.receive { suppressed.fulfill() } + successful.receive { forwarded.fulfill() } + failed.complete(succeeded: false) + successful.complete(succeeded: true) + successful.receive { } + failed.receive { suppressed.fulfill() } + await fulfillment(of: [forwarded, suppressed], timeout: 0.05) + } + + func testThrownOperationClosesItsErrorOwner() async { + let suppressed = expectation(description: "thrown error belongs to completion") + suppressed.isInverted = true + var captured: GodotIapHelper.CompletionErrorOwner? + do { + let _: Bool = try await GodotIapHelper.withCompletionErrors { + captured = GodotIapHelper.completionErrorOwner + captured?.receive { suppressed.fulfill() } + throw PurchaseError.make(code: .notPrepared) + } + XCTFail("Expected the provider failure") + } catch { + XCTAssertEqual((error as? PurchaseError)?.code, .notPrepared) + } + captured?.receive { suppressed.fulfill() } + await fulfillment(of: [suppressed], timeout: 0.05) + XCTAssertNil(GodotIapHelper.completionErrorOwner) + } + + func testProviderErrorsRetainTheirCodeAndGenericErrorsUseTheFallback() { + let unsupported = PurchaseError.make(code: .featureNotSupported, message: "Use the vendor backend") + XCTAssertEqual(GodotIapHelper.errorCode(unsupported, fallback: .purchaseVerificationFailed), "feature-not-supported") + XCTAssertEqual(GodotIapHelper.errorCode(NSError(domain: "Vendor", code: 1), fallback: .purchaseVerificationFailed), "purchase-verification-failed") + } + func testCanonicalProductQueryTypesPreserveMeaning() throws { XCTAssertEqual( try GodotIapHelper.parseProductQueryType("in-app", defaultType: .all), @@ -125,4 +196,106 @@ final class GodotIapHelperTests: XCTestCase { ]) ) } + + private func handBuiltPurchase(store: String? = "unknown", storeId: String? = "") -> [String: Any] { + var purchase: [String: Any] = [ + "id": "tx-coins", + "productId": "coins", + "transactionId": "tx-coins", + "transactionDate": 1.0, + "purchaseToken": "jws-coins", + "purchaseState": "purchased", + "quantity": 1, + "isAutoRenewing": false, + ] + if let store { purchase["store"] = store } else { purchase.removeValue(forKey: "store") } + if let storeId { purchase["storeId"] = storeId } else { purchase.removeValue(forKey: "storeId") } + return purchase + } + + func testHandBuiltBlankIdentityStampsTheConnectedProvider() throws { + let blanks: [[String: Any]] = [ + handBuiltPurchase(store: "unknown", storeId: ""), + handBuiltPurchase(store: "unknown", storeId: nil), + handBuiltPurchase(store: nil, storeId: ""), + handBuiltPurchase(store: "", storeId: " "), + ] + for input in blanks { + let stamped = GodotIapHelper.withProviderStoreIdentity(input, providerStoreId: { "apple" }) + XCTAssertEqual(stamped["store"] as? String, "apple") + XCTAssertEqual(stamped["storeId"] as? String, "apple") + let decoded = try OpenIapSerialization.purchaseInput(from: stamped) + guard case let .purchaseIos(purchase) = decoded else { + XCTFail("Expected an iOS purchase input") + continue + } + XCTAssertEqual(purchase.purchaseToken, "jws-coins") + } + } + + func testHandBuiltBlankIdentityStampsCommunityProvidersAsUnknown() throws { + let stamped = GodotIapHelper.withProviderStoreIdentity( + handBuiltPurchase(), + providerStoreId: { "community-fixture" } + ) + XCTAssertEqual(stamped["store"] as? String, "unknown") + XCTAssertEqual(stamped["storeId"] as? String, "community-fixture") + let decoded = try OpenIapSerialization.purchaseInput(from: stamped) + guard case let .purchaseIos(purchase) = decoded else { + return XCTFail("Expected an iOS purchase input") + } + XCTAssertEqual(purchase.purchaseToken, "jws-coins") + } + + func testBlankStoreIdWithOfficialStoreDropsTheKeyForDecoderInference() throws { + for blank in ["", " "] { + let dropped = GodotIapHelper.withProviderStoreIdentity( + handBuiltPurchase(store: "apple", storeId: blank), + providerStoreId: { "apple" } + ) + XCTAssertNil(dropped["storeId"]) + XCTAssertEqual(dropped["store"] as? String, "apple") + let decoded = try OpenIapSerialization.purchaseInput(from: dropped) + guard case let .purchaseIos(purchase) = decoded else { + XCTFail("Expected an iOS purchase input") + continue + } + XCTAssertEqual(purchase.storeId, "apple") + XCTAssertEqual(purchase.purchaseToken, "jws-coins") + } + } + + func testExplicitStoreIdPassesThroughUntouched() throws { + var lookups = 0 + let inputs: [[String: Any]] = [ + handBuiltPurchase(store: "unknown", storeId: "community-fixture"), + handBuiltPurchase(store: "apple", storeId: "apple"), + ] + for input in inputs { + let passed = GodotIapHelper.withProviderStoreIdentity(input, providerStoreId: { + lookups += 1 + return "apple" + }) + XCTAssertEqual(passed.count, input.count) + XCTAssertEqual(passed["store"] as? String, input["store"] as? String) + XCTAssertEqual(passed["storeId"] as? String, input["storeId"] as? String) + XCTAssertEqual(passed["purchaseToken"] as? String, "jws-coins") + } + XCTAssertEqual(lookups, 0) + let decoded = try OpenIapSerialization.purchaseInput( + from: handBuiltPurchase(store: "unknown", storeId: "community-fixture") + ) + guard case let .purchaseIos(purchase) = decoded else { + return XCTFail("Expected an iOS purchase input") + } + XCTAssertEqual(purchase.storeId, "community-fixture") + } + + func testUnreadableProviderLeavesBlankIdentityUntouched() { + let input = handBuiltPurchase() + let passed = GodotIapHelper.withProviderStoreIdentity(input, providerStoreId: { nil }) + XCTAssertEqual(passed.count, input.count) + XCTAssertEqual(passed["store"] as? String, "unknown") + XCTAssertEqual(passed["storeId"] as? String, "") + } } diff --git a/libraries/godot-iap/prebuilt-binaries.sha256 b/libraries/godot-iap/prebuilt-binaries.sha256 index b8435e51b..9074789fa 100644 --- a/libraries/godot-iap/prebuilt-binaries.sha256 +++ b/libraries/godot-iap/prebuilt-binaries.sha256 @@ -1,5 +1,6 @@ -# SHA-256 of the prebuilt frameworks committed under addons/godot-iap/bin/. -# CI builds them with Xcode 26.6 (job `apple-frameworks` in ci-godot-iap.yml) +# SHA-256 of the prebuilt frameworks committed under addons/godot-iap/bin/ +# and the Android archives committed under addons/godot-iap/android/. +# CI builds the frameworks with Xcode 26.6 (job `apple-frameworks` in ci-godot-iap.yml) # and uploads them as an artifact; the maintainer commits that artifact. # `bun run audit:godot-binaries` verifies the committed files against this # manifest — the release runs it as a deploy gate @@ -10,7 +11,9 @@ # # Regenerate after an intentional rebuild: # node scripts/audit-godot-binary-digests.mjs --write -09f0b88788d5a3ae94fd3f63d3de6593c2fa0c3565b005b49a883a815056a3b0 addons/godot-iap/bin/ios/GodotIap.framework/GodotIap -fe3b751f75f651b4a666a7dff9cf539f877fd7bfe1c957be5e0693fbcd1f33d2 addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime -9df585948eb5eb99f2e97356e8ae7e117e83e92d8ea31b83872de90b75b9a0b2 addons/godot-iap/bin/macos/GodotIap.framework/GodotIap -fe9cd929e06e8e3e641a8c76acef9948125a0af0ede5a4bc76f5fea5fe81733d addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime +0f34f5caf613bfe46dbcdfb50a4b4d5af4f3cd82f2a9330104139f14f61cf1c3 addons/godot-iap/android/GodotIap.debug.aar +081cd65f21804a7dbac60609ef7d8765cc2181aecce6067d8525e5c8215a5cd5 addons/godot-iap/android/GodotIap.release.aar +431c8168c7b87f8d3f3cbb3eee566f8ee765d5f6e58b963d867bd476c594ba92 addons/godot-iap/bin/ios/GodotIap.framework/GodotIap +a808138b23dde6447ed98fb6d8da7404bb0dadb23f39fc6609914ff74299851e addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime +8d0c4c16573ecfbcd9695808b4aea1f60b2a7ab9d76ceb0ab8a96f9d4011980d addons/godot-iap/bin/macos/GodotIap.framework/GodotIap +e52e84b5f8bd8bf37b0712bd34072a55333575da32c63a4fdad96fb08138a849 addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime diff --git a/libraries/kmp-iap/example/composeApp/build.gradle.kts b/libraries/kmp-iap/example/composeApp/build.gradle.kts index cb5960f9f..54d7c8cc0 100644 --- a/libraries/kmp-iap/example/composeApp/build.gradle.kts +++ b/libraries/kmp-iap/example/composeApp/build.gradle.kts @@ -146,6 +146,9 @@ android { create("amazon") { dimension = "platform" } + create("provider") { + dimension = "platform" + } } buildFeatures { 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 7e26024d5..8f047e8da 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 @@ -119,6 +119,7 @@ fun PurchaseFlowScreen(navController: NavController) { .toLocalDateTime(TimeZone.currentSystemDefault()) purchaseResult = """ ✅ Purchase successful (${purchase.store}) + Store ID: ${purchase.storeId} Product: ${purchase.productId} Transaction ID: ${purchase.id.ifEmpty { "N/A" }} Date: $dateText diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts index 6fe37ed8c..9e88dd582 100644 --- a/libraries/kmp-iap/library/build.gradle.kts +++ b/libraries/kmp-iap/library/build.gradle.kts @@ -240,7 +240,7 @@ fun dynamicKmpPodspec(): String = kotlin { androidTarget { - publishLibraryVariants("playRelease", "horizonRelease", "amazonRelease") + publishLibraryVariants("playRelease", "horizonRelease", "amazonRelease", "providerRelease") @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { jvmTarget.set(JvmTarget.JVM_11) @@ -319,6 +319,11 @@ android { buildConfigField("String", "OPENIAP_STORE", "\"amazon\"") consumerProguardFiles("consumer-rules-non-play.pro") } + create("provider") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"provider\"") + consumerProguardFiles("consumer-rules-non-play.pro") + } } buildFeatures { buildConfig = true @@ -334,11 +339,13 @@ dependencies { 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") + add("providerImplementation", "io.github.hyochan.openiap:openiap-core:$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:$playBillingVersion") add("amazonCompileOnly", "com.android.billingclient:billing:$playBillingVersion") + add("providerCompileOnly", "com.android.billingclient:billing:$playBillingVersion") add("androidUnitTestImplementation", "com.android.billingclient:billing:$playBillingVersion") // BillingClient Purchase parses Android JSONObject state internally; use // the same Android-aware JVM harness as packages/google for those tests. @@ -348,6 +355,12 @@ dependencies { add("androidUnitTestImplementation", "com.google.code.gson:gson:2.14.0") } +if (providers.gradleProperty("openiapProvider").isPresent) { + apply(from = "../../../packages/google/gradle/openiap-store.gradle") + val addStore = extra["openIapAddStoreDependencies"] as groovy.lang.Closure<*> + addStore.call("providerImplementation", googleVersion) +} + // Only configure publishing when we have signing credentials val hasSigningKey = localProperties.getProperty("signingInMemoryKey") != null || System.getenv("ORG_GRADLE_PROJECT_signingInMemoryKey") != null 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 1b027fff8..ba1237c10 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 @@ -505,6 +505,7 @@ internal fun com.android.billingclient.api.Purchase.toPurchase(): Purchase { pendingPurchaseUpdateAndroid = pendingUpdate, productId = products.firstOrNull() ?: "", store = IapStore.Google, + storeId = "play", purchaseState = purchaseStateEnum, purchaseToken = purchaseToken, quantity = quantity, @@ -782,8 +783,9 @@ internal fun LaunchExternalLinkParamsAndroid.toOpenIapParams(): OpenIapLaunchExt /** * Re-shapes an openiap-google IAPKit result into this module's generated types. - * openiap-google has already decoded it safely, so unknown values degrade here - * rather than re-imposing a fail-closed gate when the two versions drift. + * State and store degrade to Unknown when the two versions drift, but the + * store identity fails closed: the constructor throws on an id it cannot + * represent, and callers report that as PurchaseVerificationFailed. */ internal fun AndroidRequestVerifyPurchaseWithIapkitResult.toKmpIapkitResult(): RequestVerifyPurchaseWithIapkitResult = RequestVerifyPurchaseWithIapkitResult( @@ -807,5 +809,6 @@ internal fun AndroidRequestVerifyPurchaseWithIapkitResult.toKmpIapkitResult(): R }.getOrDefault(IapkitPurchaseState.Unknown), store = runCatching { IapStore.fromJson(store.toJson()) - }.getOrDefault(IapStore.Unknown) + }.getOrDefault(IapStore.Unknown), + storeId = storeId ) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/KmpIAP.android.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/KmpIAP.android.kt index c641e6e6b..3c7a4f3ed 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/KmpIAP.android.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/KmpIAP.android.kt @@ -15,5 +15,10 @@ actual fun createPlatformInAppPurchase(): KmpInAppPurchase = store = Store.AMAZON, versionPlatform = "Android Amazon" ) + "provider" -> OpenIapDelegateInAppPurchaseAndroid( + storeName = "provider", + store = Store.UNKNOWN, + versionPlatform = "Android provider" + ) else -> InAppPurchaseAndroid() } diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt index 397e8c8ce..bad3f63ba 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt @@ -1,6 +1,4 @@ -// The delegate serves the Amazon and Horizon flavors through OpenIapModule and -// overrides the shared 2.x compatibility methods. -// Consumer call sites retain warnings; remove the overrides in kmp-iap 3. +// Keep deprecated overrides for existing consumers. @file:Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") package io.github.hyochan.kmpiap @@ -10,10 +8,13 @@ import android.app.Application import android.content.Context import android.os.Bundle import dev.hyo.openiap.OpenIapError as AndroidOpenIapError -import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProvider import dev.hyo.openiap.OpenIapProtocol as AndroidOpenIapProtocol import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener +import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.openiap.utils.verifyPurchaseWithIapkit as verifyPurchaseWithIapkitAndroid import io.github.hyochan.kmpiap.openiap.ActiveSubscription import io.github.hyochan.kmpiap.openiap.AppTransaction @@ -109,10 +110,17 @@ internal class OpenIapDelegateInAppPurchaseAndroid( override val promotedProductListener: Flow = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST).asSharedFlow() - override val subscriptionBillingIssueListener: Flow = emptyFlow() + private val billingIssues = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val subscriptionBillingIssueListener: Flow = + if (store == Store.UNKNOWN) billingIssues.asSharedFlow() else emptyFlow() + private val userChoices = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val developerChoices = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private var userChoiceListener: OpenIapUserChoiceBillingListener? = null + private var developerChoiceListener: OpenIapDeveloperProvidedBillingListener? = null private var updateListener: OpenIapPurchaseUpdateListener? = null private var errorListener: OpenIapPurchaseErrorListener? = null + private var billingIssueListener: OpenIapSubscriptionBillingIssueListener? = null override suspend fun initConnection(config: InitConnectionConfig?): Boolean = withContext(Dispatchers.Main) { connectionMutex.withLock { @@ -134,9 +142,7 @@ internal class OpenIapDelegateInAppPurchaseAndroid( override suspend fun endConnection(): Boolean = withContext(Dispatchers.IO) { connectionMutex.withLock { val openModule = module ?: return@withLock true - // Keep listeners attached through the native end call. Amazon emits - // pending purchase disconnects synchronously before returning, so - // KMP receives the typed error and can then release the old module. + // Amazon emits pending purchase errors during teardown, before listeners detach. try { endDelegatedConnectionWithCleanup( endConnection = { openModule.endConnection() }, @@ -185,18 +191,17 @@ internal class OpenIapDelegateInAppPurchaseAndroid( override suspend fun getActiveSubscriptions(subscriptionIds: List?): List = withMappedOpenIapError { - requireModule().queryHandlers.getActiveSubscriptions?.invoke(subscriptionIds) - ?.map { ActiveSubscription.fromJson(it.toJson()) } - ?: emptyList() + requireModule().getActiveSubscriptions(subscriptionIds) + .map { ActiveSubscription.fromJson(it.toJson()) } } override suspend fun hasActiveSubscriptions(subscriptionIds: List?): Boolean = withMappedOpenIapError { - requireModule().queryHandlers.hasActiveSubscriptions?.invoke(subscriptionIds) ?: false + requireModule().hasActiveSubscriptions(subscriptionIds) } override suspend fun restorePurchases() { - withMappedOpenIapError { requireModule().mutationHandlers.restorePurchases?.invoke() } + withMappedOpenIapError { requireModule().restorePurchases() } } override suspend fun finishTransaction(purchase: PurchaseInput, isConsumable: Boolean?) { @@ -214,17 +219,15 @@ internal class OpenIapDelegateInAppPurchaseAndroid( override suspend fun deepLinkToSubscriptions(options: DeepLinkOptions?) { withMappedOpenIapError { - options?.let { - requireModule().mutationHandlers.deepLinkToSubscriptions?.invoke(it.toOpenIap()) + if (options != null || store == Store.UNKNOWN) { + requireModule().deepLinkToSubscriptions(options?.toOpenIap()) } } } override suspend fun getStorefront(): String = withMappedOpenIapError { - val handler = requireModule().queryHandlers.getStorefront - ?: failUnsupported("$storeName storefront query is unavailable.") - authoritativeStorefrontCountryOrNull(handler()) ?: failWith( + authoritativeStorefrontCountryOrNull(requireModule().getStorefront()) ?: failWith( PurchaseError( code = ErrorCode.ServiceError, message = "$storeName returned no authoritative storefront country code", @@ -233,6 +236,14 @@ internal class OpenIapDelegateInAppPurchaseAndroid( } override suspend fun verifyPurchaseWithProvider(options: VerifyPurchaseWithProviderProps): VerifyPurchaseWithProviderResult { + if (store == Store.UNKNOWN) { + return withMappedOpenIapError { + val result = requireModule().verifyPurchaseWithProvider( + requireNotNull(dev.hyo.openiap.VerifyPurchaseWithProviderProps.fromJson(options.toJson())) + ) + VerifyPurchaseWithProviderResult.fromJson(result.toJson()) + } + } if (options.provider != PurchaseVerificationProvider.Iapkit) { failUnsupported("Verification provider ${options.provider.rawValue} is not supported on Android") } @@ -252,9 +263,7 @@ internal class OpenIapDelegateInAppPurchaseAndroid( ) ) } - // The validator also throws IllegalArgumentException for a malformed - // options shape, so catch broadly like the Play path rather than only - // the typed OpenIapError. + // Validation can throw an untyped error for malformed options. return try { val androidResult = verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") @@ -275,36 +284,75 @@ internal class OpenIapDelegateInAppPurchaseAndroid( } override suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult = - failUnsupported("verifyPurchase is not supported on Android. Use verifyPurchaseWithProvider for server-side verification via IAPKit.") + if (store == Store.UNKNOWN) withMappedOpenIapError { + VerifyPurchaseResult.fromJson(requireModule().verifyPurchase( + dev.hyo.openiap.VerifyPurchaseProps.fromJson(options.toJson()) + ).toJson()) + } else failUnsupported("verifyPurchase is not supported on Android. Use verifyPurchaseWithProvider for server-side verification via IAPKit.") override suspend fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid = - BillingProgramAvailabilityResultAndroid(billingProgram = program, isAvailable = false) + if (store == Store.UNKNOWN) withMappedOpenIapError { + BillingProgramAvailabilityResultAndroid.fromJson(requireModule().isBillingProgramAvailable( + dev.hyo.openiap.BillingProgramAndroid.fromJson(program.rawValue) + ).toJson()) + } else BillingProgramAvailabilityResultAndroid(billingProgram = program, isAvailable = false) override suspend fun getBillingChoiceInfoAndroid(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid = - failUnsupported("Google Play Billing Choice is unavailable on $storeName.") + if (store == Store.UNKNOWN) withMappedOpenIapError { + BillingChoiceInfoAndroid.fromJson(requireModule().getBillingChoiceInfo( + requireNotNull(dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid.fromJson(params.toJson())) + ).toJson()) + } else failUnsupported("Google Play Billing Choice is unavailable on $storeName.") override suspend fun createBillingProgramReportingDetailsAndroid( program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid? ): BillingProgramReportingDetailsAndroid = - failUnsupported("Google Play billing programs are unavailable on $storeName.") + if (store == Store.UNKNOWN) withMappedOpenIapError { + BillingProgramReportingDetailsAndroid.fromJson(requireModule().createBillingProgramReportingDetails( + dev.hyo.openiap.BillingProgramAndroid.fromJson(program.rawValue), + developerBillingType?.let { dev.hyo.openiap.DeveloperBillingTypeAndroid.fromJson(it.rawValue) } + ).toJson()) + } else failUnsupported("Google Play billing programs are unavailable on $storeName.") override suspend fun showBillingProgramInformationDialogAndroid(params: BillingProgramInformationDialogParamsAndroid): BillingResultAndroid = - failUnsupported("Google Play Billing Choice is unavailable on $storeName.") + if (store == Store.UNKNOWN) withMappedOpenIapError { + BillingResultAndroid.fromJson(requireModule().showBillingProgramInformationDialog( + requireActivity(), + requireNotNull(dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid.fromJson(params.toJson())) + ).toJson()) + } else failUnsupported("Google Play Billing Choice is unavailable on $storeName.") 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 - // Amazon and Horizon have no offer-code redemption surface; resolve null without launching. - override suspend fun openRedeemOfferCode(): Purchase? = null - override suspend fun openRedeemOfferCodeAndroid(): Boolean = false + if (store == Store.UNKNOWN) withMappedOpenIapError { + InAppMessageResultAndroid.fromJson(requireModule().showInAppMessages( + requireActivity(), params?.let { dev.hyo.openiap.InAppMessageParamsAndroid.fromJson(it.toJson()) } + ).toJson()) + } else failUnsupported("Google Play billing in-app messages are unavailable on $storeName.") + + override suspend fun launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid): Boolean = + if (store == Store.UNKNOWN) withMappedOpenIapError { + requireModule().launchExternalLink( + requireActivity(), requireNotNull(dev.hyo.openiap.LaunchExternalLinkParamsAndroid.fromJson(params.toJson())) + ) + } else false + override suspend fun openRedeemOfferCode(): Purchase? { + if (store == Store.UNKNOWN) openRedeemOfferCodeAndroid() + return null + } + override suspend fun openRedeemOfferCodeAndroid(): Boolean = + if (store == Store.UNKNOWN) withMappedOpenIapError { + requireModule().openRedeemOfferCode(requireActivity()) + } else false override suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails = - failUnsupported("User Choice Billing is unavailable on $storeName.") + if (store == Store.UNKNOWN) userChoices.first() + else failUnsupported("User Choice Billing is unavailable on $storeName.") override suspend fun developerProvidedBillingAndroid(): DeveloperProvidedBillingDetailsAndroid = - failUnsupported("Developer-provided billing is unavailable on $storeName.") + if (store == Store.UNKNOWN) developerChoices.first() + else failUnsupported("Developer-provided billing is unavailable on $storeName.") override suspend fun subscriptionBillingIssue(): Purchase = - failUnsupported("Subscription billing-issue events are unavailable on $storeName.") + if (store == Store.UNKNOWN) subscriptionBillingIssueListener.first() + else failUnsupported("Subscription billing-issue events are unavailable on $storeName.") override suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions?): Purchase = purchaseUpdatedListener(options).first() override suspend fun purchaseError(): PurchaseError = purchaseErrorListener.first() override suspend fun promotedProductIOS(): String = "" @@ -374,10 +422,12 @@ internal class OpenIapDelegateInAppPurchaseAndroid( failWith(PurchaseError(code = ErrorCode.ServiceError, message = "Context not available")) } + private fun requireActivity(): Activity = currentActivity ?: throw AndroidOpenIapError.MissingCurrentActivity + private fun requireModule(): AndroidOpenIapProtocol = module ?: failWith(PurchaseError(code = ErrorCode.NotPrepared, message = "$storeName billing module not initialized")) - private fun buildOpenIapModule(ctx: Context): AndroidOpenIapProtocol = OpenIapModule(ctx) + private fun buildOpenIapModule(ctx: Context): AndroidOpenIapProtocol = OpenIapProvider.create(ctx) private fun registerListeners(openModule: AndroidOpenIapProtocol) { val purchaseUpdate = OpenIapPurchaseUpdateListener { purchase -> @@ -388,6 +438,21 @@ internal class OpenIapDelegateInAppPurchaseAndroid( } openModule.addPurchaseUpdateListener(purchaseUpdate) openModule.addPurchaseErrorListener(purchaseError) + if (store == Store.UNKNOWN) { + val issue = OpenIapSubscriptionBillingIssueListener { purchase -> billingIssues.tryEmit(purchase.toKmp()) } + openModule.addSubscriptionBillingIssueListener(issue) + billingIssueListener = issue + val userChoice = OpenIapUserChoiceBillingListener { details -> + userChoices.tryEmit(UserChoiceBillingDetails.fromJson(details.toJson())) + } + val developerChoice = OpenIapDeveloperProvidedBillingListener { details -> + developerChoices.tryEmit(DeveloperProvidedBillingDetailsAndroid.fromJson(details.toJson())) + } + openModule.addUserChoiceBillingListener(userChoice) + openModule.addDeveloperProvidedBillingListener(developerChoice) + userChoiceListener = userChoice + developerChoiceListener = developerChoice + } updateListener = purchaseUpdate errorListener = purchaseError @@ -396,8 +461,14 @@ internal class OpenIapDelegateInAppPurchaseAndroid( private fun unregisterListeners(openModule: AndroidOpenIapProtocol) { updateListener?.let(openModule::removePurchaseUpdateListener) errorListener?.let(openModule::removePurchaseErrorListener) + billingIssueListener?.let(openModule::removeSubscriptionBillingIssueListener) + userChoiceListener?.let(openModule::removeUserChoiceBillingListener) + developerChoiceListener?.let(openModule::removeDeveloperProvidedBillingListener) + userChoiceListener = null + developerChoiceListener = null updateListener = null errorListener = null + billingIssueListener = null } private suspend fun withMappedOpenIapError(block: suspend () -> T): T = @@ -417,9 +488,7 @@ internal class OpenIapDelegateInAppPurchaseAndroid( } internal fun AndroidOpenIapError.toKmpPurchaseError(): PurchaseError { - // toJSON() already resolves intrinsic ProductNotFound/SkuNotFound IDs and - // per-request requestProductId diagnostics. Do not overwrite it with null - // for other typed errors such as Amazon cancellation or deferred purchase. + // Preserve native product IDs and request diagnostics. val payload = toJSON() + mapOf( "message" to (debugMessage?.takeIf { it.isNotBlank() } ?: message), ) diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingQueryLifecycleTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingQueryLifecycleTest.kt index 2cc51fb26..e630b1ce1 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingQueryLifecycleTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/BillingQueryLifecycleTest.kt @@ -460,6 +460,7 @@ class BillingQueryLifecycleTest { purchaseToken = token, quantity = 1, store = IapStore.Google, + storeId = "play", transactionDate = 0.0, ) diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunityOptionalOperationsTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunityOptionalOperationsTest.kt new file mode 100644 index 000000000..60bc09dc0 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunityOptionalOperationsTest.kt @@ -0,0 +1,172 @@ +package io.github.hyochan.kmpiap + +import android.app.Activity +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.MutationEndConnectionHandler +import dev.hyo.openiap.MutationVerifyPurchaseHandler +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener +import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener +import io.github.hyochan.kmpiap.openiap.* +import java.lang.reflect.Proxy +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CommunityOptionalOperationsTest { + @Test + fun publicOperationsMapInputsAndResultsWithoutOptionalTables() = runBlocking { + val calls = mutableListOf() + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val verification: MutationVerifyPurchaseHandler = { input -> + assertEquals("opaque-token", input.google?.purchaseToken) + dev.hyo.openiap.VerifyPurchaseResultAndroid.fromJson(mapOf( + "isValid" to true, "receiptId" to "community-receipt", "productId" to "product", "quantity" to 2, + )) + } + val provider = provider { method, args -> + calls.add(method) + when (method) { + "getVerifyPurchase" -> verification + "isBillingProgramAvailable" -> { + assertEquals(dev.hyo.openiap.BillingProgramAndroid.BillingChoice, args?.get(0)) + dev.hyo.openiap.BillingProgramAvailabilityResultAndroid(billingProgram = dev.hyo.openiap.BillingProgramAndroid.BillingChoice, isAvailable = true) + } + "getBillingChoiceInfo" -> { + assertEquals("en-US", (args?.get(0) as dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid).userLocale) + dev.hyo.openiap.BillingChoiceInfoAndroid("https://example.test/image", "loyalty") + } + "createBillingProgramReportingDetails" -> { + assertEquals(dev.hyo.openiap.BillingProgramAndroid.BillingChoice, args?.get(0)) + assertEquals(dev.hyo.openiap.DeveloperBillingTypeAndroid.InApp, args?.get(1)) + dev.hyo.openiap.BillingProgramReportingDetailsAndroid(dev.hyo.openiap.BillingProgramAndroid.BillingChoice, "external-token") + } + "showBillingProgramInformationDialog" -> { + assertEquals(activity, args?.get(0)) + assertEquals("external-token", (args?.get(1) as dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid).externalTransactionToken) + dev.hyo.openiap.BillingResultAndroid(debugMessage = "opened", responseCode = 0) + } + "showInAppMessages" -> { + assertEquals(activity, args?.get(0)) + assertEquals(null, args?.get(1)) + dev.hyo.openiap.InAppMessageResultAndroid("opaque-token", dev.hyo.openiap.InAppMessageResponseCodeAndroid.SubscriptionStatusUpdated) + } + "launchExternalLink" -> { + assertEquals(activity, args?.get(0)) + assertEquals("https://example.test/pay", (args?.get(1) as dev.hyo.openiap.LaunchExternalLinkParamsAndroid).linkUri) + true + } + "openRedeemOfferCode" -> { assertEquals(activity, args?.get(0)); true } + else -> error("Unexpected call: $method") + } + } + val delegate = delegate(provider, activity) + val verified = delegate.verifyPurchase(VerifyPurchaseProps.fromJson(mapOf("google" to mapOf( + "purchaseToken" to "opaque-token", "accessToken" to "test-access", "packageName" to "test.package", "sku" to "product", + )))) as VerifyPurchaseResultAndroid + assertTrue(verified.isValid) + assertEquals("community-receipt", verified.receiptId) + assertEquals(2, verified.quantity) + assertTrue(delegate.isBillingProgramAvailableAndroid(BillingProgramAndroid.BillingChoice).isAvailable) + assertEquals("loyalty", delegate.getBillingChoiceInfoAndroid(GetBillingChoiceInfoParamsAndroid(userLocale = "en-US")).playBillingLoyaltyInfo) + assertEquals("external-token", delegate.createBillingProgramReportingDetailsAndroid(BillingProgramAndroid.BillingChoice, DeveloperBillingTypeAndroid.InApp).externalTransactionToken) + assertEquals("opened", delegate.showBillingProgramInformationDialogAndroid(BillingProgramInformationDialogParamsAndroid(externalTransactionToken = "external-token")).debugMessage) + assertEquals("opaque-token", delegate.showInAppMessagesAndroid(null).purchaseToken) + assertTrue(delegate.launchExternalLinkAndroid(linkParams())) + assertTrue(delegate.openRedeemOfferCodeAndroid()) + assertEquals(8, calls.size) + } + + @Test + fun providerFailuresAndMissingActivityRemainTyped() = runBlocking { + val failing = delegate(provider { _, args -> + @Suppress("UNCHECKED_CAST") + val continuation = args!!.last() as Continuation + continuation.resumeWith(Result.failure(OpenIapError.FeatureNotSupported("Provider declines this operation"))) + COROUTINE_SUSPENDED + }) + val error = assertFailsWith { failing.isBillingProgramAvailableAndroid(BillingProgramAndroid.BillingChoice) } + assertEquals(ErrorCode.FeatureNotSupported, error.error.code) + assertEquals("Provider declines this operation", error.error.message) + val ui = delegate(provider { method, _ -> error("Must not invoke $method without an Activity") }) + for (operation in listOf Any?>( + { ui.showInAppMessagesAndroid(null) }, + { ui.showBillingProgramInformationDialogAndroid(BillingProgramInformationDialogParamsAndroid(externalTransactionToken = "token")) }, + { ui.launchExternalLinkAndroid(linkParams()) }, + { ui.openRedeemOfferCodeAndroid() }, + )) assertEquals(ErrorCode.ActivityUnavailable, assertFailsWith { operation() }.error.code) + } + + @Test + fun billingSelectionEventsReachCollectorsAndDetachAtEndConnection() = runBlocking { + val listeners = mutableMapOf() + val end: MutationEndConnectionHandler = { true } + val provider = provider { method, args -> + when { + method.startsWith("add") -> { listeners[method.removePrefix("add")] = args!![0]!!; null } + method.startsWith("remove") -> { assertEquals(listeners.remove(method.removePrefix("remove")), args!![0]); null } + method == "getEndConnection" -> end + else -> error("Unexpected call: $method") + } + } + val delegate = delegate(provider) + delegate.javaClass.getDeclaredMethod("registerListeners", OpenIapProtocol::class.java).apply { isAccessible = true }.invoke(delegate, provider) + val user = async(start = CoroutineStart.UNDISPATCHED) { delegate.userChoiceBillingAndroid() } + val developer = async(start = CoroutineStart.UNDISPATCHED) { delegate.developerProvidedBillingAndroid() } + (listeners.getValue("UserChoiceBillingListener") as OpenIapUserChoiceBillingListener).onUserChoiceBilling( + dev.hyo.openiap.UserChoiceBillingDetails.fromJson(mapOf("externalTransactionToken" to "user-token", "products" to listOf("product"), "originalExternalTransactionId" to "original")) + ) + (listeners.getValue("DeveloperProvidedBillingListener") as OpenIapDeveloperProvidedBillingListener).onDeveloperProvidedBilling( + dev.hyo.openiap.DeveloperProvidedBillingDetailsAndroid.fromJson(mapOf("externalTransactionToken" to "developer-token", "products" to emptyList(), "linkUri" to "https://example.test/pay")) + ) + val userResult = withTimeout(5_000) { user.await() } + assertEquals("user-token", userResult.externalTransactionToken) + assertEquals(listOf("product"), userResult.products) + assertEquals("original", userResult.originalExternalTransactionId) + assertEquals("https://example.test/pay", withTimeout(5_000) { developer.await() }.linkUri) + assertTrue(delegate.endConnection()) + assertTrue(listeners.isEmpty()) + } + + @Test + fun officialNonPlayStoresRetainUnsupportedOperations() = runBlocking { + for (store in listOf(Store.AMAZON, Store.HORIZON)) { + val delegate = OpenIapDelegateInAppPurchaseAndroid(store.name, store, "Android") + assertFalse(delegate.isBillingProgramAvailableAndroid(BillingProgramAndroid.BillingChoice).isAvailable) + assertFalse(delegate.launchExternalLinkAndroid(linkParams())) + for (operation in listOf Any?>( + { delegate.verifyPurchase(VerifyPurchaseProps()) }, + { delegate.getBillingChoiceInfoAndroid(GetBillingChoiceInfoParamsAndroid()) }, + { delegate.createBillingProgramReportingDetailsAndroid(BillingProgramAndroid.BillingChoice, null) }, + { delegate.showBillingProgramInformationDialogAndroid(BillingProgramInformationDialogParamsAndroid(externalTransactionToken = "token")) }, + )) assertEquals(ErrorCode.FeatureNotSupported, assertFailsWith { operation() }.error.code) + } + } + + private fun delegate(provider: OpenIapProtocol, activity: Activity? = null): OpenIapDelegateInAppPurchaseAndroid = + OpenIapDelegateInAppPurchaseAndroid("community", Store.UNKNOWN, "Android Community").also { delegate -> + delegate.javaClass.getDeclaredField("module").apply { isAccessible = true }.set(delegate, provider) + delegate.javaClass.getDeclaredField("currentActivity").apply { isAccessible = true }.set(delegate, activity) + } + + private fun provider(invoke: (String, Array?) -> Any?): OpenIapProtocol = + Proxy.newProxyInstance(OpenIapProtocol::class.java.classLoader, arrayOf(OpenIapProtocol::class.java)) { _, method, args -> invoke(method.name, args) } as OpenIapProtocol + + private fun linkParams() = LaunchExternalLinkParamsAndroid.fromJson(mapOf( + "billingProgram" to "billing-choice", "launchMode" to "launch-in-external-browser-or-app", "linkType" to "link-to-digital-content-offer", "linkUri" to "https://example.test/pay", + ))!! +} diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunitySubscriptionManagementTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunitySubscriptionManagementTest.kt new file mode 100644 index 000000000..d8440603b --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunitySubscriptionManagementTest.kt @@ -0,0 +1,97 @@ +package io.github.hyochan.kmpiap + +import dev.hyo.openiap.DeepLinkOptions as AndroidDeepLinkOptions +import dev.hyo.openiap.MutationDeepLinkToSubscriptionsHandler +import dev.hyo.openiap.MutationRestorePurchasesHandler +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.QueryGetActiveSubscriptionsHandler +import dev.hyo.openiap.QueryHasActiveSubscriptionsHandler +import io.github.hyochan.kmpiap.openiap.DeepLinkOptions +import java.lang.reflect.Proxy +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CommunitySubscriptionManagementTest { + @Test + fun storefrontOverrideDoesNotNeedAnOptionalQueryHandler() = runBlocking { + var calls = 0 + val provider = Proxy.newProxyInstance( + OpenIapProtocol::class.java.classLoader, + arrayOf(OpenIapProtocol::class.java), + ) { _, method, _ -> + check(method.name == "getStorefront") { "Unexpected call: ${method.name}" } + calls++ + "US" + } as OpenIapProtocol + for (store in listOf(Store.UNKNOWN, Store.AMAZON, Store.HORIZON)) { + val delegate = OpenIapDelegateInAppPurchaseAndroid(store.name, store, "Android") + delegate.javaClass.getDeclaredField("module").apply { isAccessible = true }.set(delegate, provider) + assertEquals("US", delegate.getStorefront()) + } + assertEquals(3, calls) + } + + @Test + fun requiredHandlersDoNotNeedOptionalDispatchTables() = runBlocking { + val queries = mutableListOf?>() + var restores = 0 + val active: QueryGetActiveSubscriptionsHandler = { queries.add(it); emptyList() } + val hasActive: QueryHasActiveSubscriptionsHandler = { queries.add(it); true } + val restore: MutationRestorePurchasesHandler = { restores++ } + val provider = Proxy.newProxyInstance( + OpenIapProtocol::class.java.classLoader, + arrayOf(OpenIapProtocol::class.java), + ) { _, method, _ -> + when (method.name) { + "getGetActiveSubscriptions" -> active + "getHasActiveSubscriptions" -> hasActive + "getRestorePurchases" -> restore + else -> error("Unexpected call: ${method.name}") + } + } as OpenIapProtocol + val delegate = OpenIapDelegateInAppPurchaseAndroid("community", Store.UNKNOWN, "Android Community") + delegate.javaClass.getDeclaredField("module").apply { isAccessible = true }.set(delegate, provider) + + assertTrue(delegate.getActiveSubscriptions(listOf("subscription")).isEmpty()) + assertTrue(delegate.hasActiveSubscriptions(null)) + delegate.restorePurchases() + + assertEquals(listOf(listOf("subscription"), null), queries) + assertEquals(1, restores) + } + + @Test + fun defaultOptionsReachTheProvider() = runBlocking { + val requests = mutableListOf() + val handler: MutationDeepLinkToSubscriptionsHandler = { requests.add(it) } + val provider = Proxy.newProxyInstance( + OpenIapProtocol::class.java.classLoader, + arrayOf(OpenIapProtocol::class.java), + ) { _, method, _ -> + check(method.name == "getDeepLinkToSubscriptions") { "Unexpected call: ${method.name}" } + handler + } as OpenIapProtocol + val delegate = OpenIapDelegateInAppPurchaseAndroid("community", Store.UNKNOWN, "Android Community") + delegate.javaClass.getDeclaredField("module").apply { isAccessible = true }.set(delegate, provider) + + delegate.deepLinkToSubscriptions(null) + delegate.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = "subscription")) + + assertEquals(2, requests.size) + assertNull(requests[0]) + assertEquals("subscription", requests[1]?.skuAndroid) + for (store in listOf(Store.AMAZON, Store.HORIZON)) { + val official = OpenIapDelegateInAppPurchaseAndroid(store.name, store, "Android") + official.javaClass.getDeclaredField("module").apply { isAccessible = true }.set(official, provider) + val before = requests.size + official.deepLinkToSubscriptions(null) + assertEquals(before, requests.size) + official.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = "official-subscription")) + assertEquals(before + 1, requests.size) + assertEquals("official-subscription", requests.last()?.skuAndroid) + } + } +} diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/FirstPurchaseNoticeTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/FirstPurchaseNoticeTest.kt index 9dc919f97..b524b87ce 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/FirstPurchaseNoticeTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/FirstPurchaseNoticeTest.kt @@ -229,6 +229,7 @@ class FirstPurchaseNoticeTest { purchaseToken = "purchase-token", quantity = 1, store = IapStore.Google, + storeId = "play", transactionDate = 0.0, ) diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt index c51bafe67..f16e789e5 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt @@ -32,7 +32,7 @@ class IapkitBaseUrlBridgeTest { assertTrue(helperSource.contains("environment = environment")) // Unknown values degrade; openiap-google already decoded them safely. assertTrue(helperSource.contains("getOrDefault(IapkitPurchaseState.Unknown)")) - assertTrue(iosSource.contains("environment = environment")) + assertTrue(iosSource.contains("\"environment\" to environment")) // Forwarded opaquely: `environment` is String in the spec. assertTrue(iosSource.contains("map[\"environment\"] as? String")) } diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitResultMappingTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitResultMappingTest.kt new file mode 100644 index 000000000..44c64a783 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitResultMappingTest.kt @@ -0,0 +1,63 @@ +package io.github.hyochan.kmpiap + +import dev.hyo.openiap.IapStore as AndroidIapStore +import dev.hyo.openiap.IapkitPurchaseState as AndroidIapkitPurchaseState +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitResult as AndroidRequestVerifyPurchaseWithIapkitResult +import io.github.hyochan.kmpiap.openiap.IapStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class IapkitResultMappingTest { + @Test + fun `official iapkit identities map through unchanged`() { + val cases = listOf( + Triple(AndroidIapStore.Google, IapStore.Google, "play"), + Triple(AndroidIapStore.Horizon, IapStore.Horizon, "horizon"), + Triple(AndroidIapStore.Amazon, IapStore.Amazon, "amazon"), + ) + for ((androidStore, expectedStore, expectedStoreId) in cases) { + val mapped = AndroidRequestVerifyPurchaseWithIapkitResult( + isValid = true, + state = AndroidIapkitPurchaseState.Entitled, + store = androidStore, + ).toKmpIapkitResult() + assertEquals(expectedStore, mapped.store) + assertEquals(expectedStoreId, mapped.storeId) + } + } + + @Test + fun `community iapkit identities map through unchanged`() { + val mapped = AndroidRequestVerifyPurchaseWithIapkitResult( + isValid = true, + state = AndroidIapkitPurchaseState.Entitled, + store = AndroidIapStore.Unknown, + clientPayload = null, + productId = null, + environment = null, + storeId = "community-fixture", + ).toKmpIapkitResult() + assertEquals(IapStore.Unknown, mapped.store) + assertEquals("community-fixture", mapped.storeId) + } + + @Test + fun `an identity the generated types cannot represent fails closed`() { + val android = AndroidRequestVerifyPurchaseWithIapkitResult( + isValid = true, + state = AndroidIapkitPurchaseState.Entitled, + store = AndroidIapStore.Unknown, + clientPayload = null, + productId = null, + environment = null, + storeId = "community-fixture", + ) + // A drifted openiap-google may stamp an id this build rejects. Public + // constructors forbid that today, so corrupt it reflectively. + android.javaClass.getDeclaredField("storeId").apply { isAccessible = true } + .set(android, "unknown") + // Callers catch Exception and report PurchaseVerificationFailed. + assertFailsWith { android.toKmpIapkitResult() } + } +} diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/PlatformStoreTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/PlatformStoreTest.kt index cc8f43e06..719a03f6b 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/PlatformStoreTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/PlatformStoreTest.kt @@ -11,6 +11,7 @@ class PlatformStoreTest { "play" to Store.PLAY_STORE, "horizon" to Store.HORIZON, "amazon" to Store.AMAZON, + "provider" to Store.UNKNOWN, ).getValue(BuildConfig.OPENIAP_STORE) assertEquals(expected, createPlatformInAppPurchase().getStore()) diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/StoreModels.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/StoreModels.kt index 6334a9601..e04327872 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/StoreModels.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/StoreModels.kt @@ -5,7 +5,8 @@ enum class Store { PLAY_STORE, AMAZON, APP_STORE, - HORIZON + HORIZON, + UNKNOWN } data class ConnectionResult( 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 f0dfdbf12..2cb73a42d 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 @@ -8,6 +8,27 @@ package io.github.hyochan.kmpiap.openiap +public object StoreIds { + const val Apple = "apple" + const val Play = "play" + const val Horizon = "horizon" + const val Amazon = "amazon" +} + +private fun resolveStoreId(store: IapStore, value: Any?): String { + val official = when (store) { + IapStore.Apple -> "apple" + IapStore.Google -> "play" + IapStore.Horizon -> "horizon" + IapStore.Amazon -> "amazon" + IapStore.Unknown -> null + } + require(value == null || value is String) { "storeId must be a string" } + val id = value as String? ?: official + require(id != null && if (official != null) id == official else id.matches(Regex("[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*")) && id !in setOf("auto", "none", "unknown", "apple", "play", "google", "horizon", "amazon")) { "Invalid store identity" } + return id +} + // MARK: - Enums /** @@ -794,6 +815,9 @@ public enum class IapPlatform(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Frozen legacy store discriminator. Use storeId for extensible store identity. + */ public enum class IapStore(val rawValue: String) { Unknown("unknown"), Apple("apple"), @@ -1438,6 +1462,9 @@ public interface PurchaseCommon { val currentPlanId: String? val id: String val ids: List? + /** + * Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. + */ val isAutoRenewing: Boolean val productId: String val purchaseState: PurchaseState @@ -1450,6 +1477,10 @@ public interface PurchaseCommon { * Store where purchase was made */ val store: IapStore + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + val storeId: String /** * Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -1469,6 +1500,9 @@ public interface VerifyPurchaseResultCommon { // MARK: - Objects public data class ActiveSubscription( + /** + * Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. + */ val autoRenewingAndroid: Boolean? = null, val basePlanIdAndroid: String? = null, /** @@ -2983,6 +3017,9 @@ public data class ProductSubscriptionIOS( } public data class PurchaseAndroid( + /** + * Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. + */ val autoRenewingAndroid: Boolean? = null, override val currentPlanId: String? = null, val dataAndroid: String? = null, @@ -2990,6 +3027,9 @@ public data class PurchaseAndroid( override val id: String, override val ids: List? = null, val isAcknowledgedAndroid: Boolean? = null, + /** + * Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. + */ override val isAutoRenewing: Boolean, /** * Whether the subscription is suspended (Android) @@ -3018,6 +3058,10 @@ public data class PurchaseAndroid( * Store where purchase was made */ override val store: IapStore, + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + override val storeId: String, /** * Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -3038,6 +3082,7 @@ public data class PurchaseAndroid( companion object { fun fromJson(json: Map): PurchaseAndroid { + val store = IapStore.fromJson(json["store"] as? String ?: "") return PurchaseAndroid( autoRenewingAndroid = json["autoRenewingAndroid"] as? Boolean, currentPlanId = json["currentPlanId"] as? String, @@ -3057,7 +3102,8 @@ public data class PurchaseAndroid( 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 = store, + storeId = resolveStoreId(store, json["storeId"]), transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String, userIdAmazon = json["userIdAmazon"] as? String, @@ -3087,6 +3133,7 @@ public data class PurchaseAndroid( "quantity" to quantity, "signatureAndroid" to signatureAndroid, "store" to store.toJson(), + "storeId" to storeId, "transactionDate" to transactionDate, "transactionId" to transactionId, "userIdAmazon" to userIdAmazon, @@ -3203,6 +3250,9 @@ public data class PurchaseIOS( val expirationDateIOS: Double? = null, override val id: String, override val ids: List? = null, + /** + * Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. + */ override val isAutoRenewing: Boolean, val isUpgradedIOS: Boolean? = null, val offerIOS: PurchaseOfferIOS? = null, @@ -3240,6 +3290,10 @@ public data class PurchaseIOS( */ override val store: IapStore, val storefrontCountryCodeIOS: String? = null, + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + override val storeId: String, val subscriptionGroupIdIOS: String? = null, /** * Unix timestamp in milliseconds since January 1, 1970 UTC. @@ -3252,6 +3306,7 @@ public data class PurchaseIOS( companion object { fun fromJson(json: Map): PurchaseIOS { + val store = IapStore.fromJson(json["store"] as? String ?: "") return PurchaseIOS( advancedCommerceInfoIOS = (json["advancedCommerceInfoIOS"] as? Map)?.let { AdvancedCommerceInfoIOS.fromJson(it) }, appAccountToken = json["appAccountToken"] as? String, @@ -3288,8 +3343,9 @@ public data class PurchaseIOS( revocationDateIOS = (json["revocationDateIOS"] as? Number)?.toDouble(), revocationReasonIOS = json["revocationReasonIOS"] as? String, revocationTypeIOS = json["revocationTypeIOS"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = store, storefrontCountryCodeIOS = json["storefrontCountryCodeIOS"] as? String, + storeId = resolveStoreId(store, json["storeId"]), subscriptionGroupIdIOS = json["subscriptionGroupIdIOS"] as? String, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String ?: "", @@ -3338,6 +3394,7 @@ public data class PurchaseIOS( "revocationTypeIOS" to revocationTypeIOS, "store" to store.toJson(), "storefrontCountryCodeIOS" to storefrontCountryCodeIOS, + "storeId" to storeId, "subscriptionGroupIdIOS" to subscriptionGroupIdIOS, "transactionDate" to transactionDate, "transactionId" to transactionId, @@ -3579,7 +3636,7 @@ public data class RequestPurchaseResultPurchase(val value: Purchase?) : RequestP public data class RequestPurchaseResultPurchases(val value: List?) : RequestPurchaseResult -public data class RequestVerifyPurchaseWithIapkitResult( +public class RequestVerifyPurchaseWithIapkitResult private constructor( /** * True when the purchase is valid and actionable. * Only entitled, pending-acknowledgment, or ready-to-consume return true. @@ -3591,7 +3648,8 @@ public data class RequestVerifyPurchaseWithIapkitResult( * The current state of the purchase. */ val state: IapkitPurchaseState, - val store: IapStore + val store: IapStore, + explicitStoreId: String?, ) { /** @@ -3623,6 +3681,24 @@ public data class RequestVerifyPurchaseWithIapkitResult( var environment: String? = null private set + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + var storeId: String = explicitStoreId?.let { resolveStoreId(store, it) } ?: when (store) { IapStore.Apple -> StoreIds.Apple; IapStore.Google -> StoreIds.Play; IapStore.Horizon -> StoreIds.Horizon; IapStore.Amazon -> StoreIds.Amazon; IapStore.Unknown -> throw IllegalArgumentException("RequestVerifyPurchaseWithIapkitResult with store Unknown requires an explicit valid storeId") } + private set + + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + ) : this( + isValid = isValid, + state = state, + store = store, + explicitStoreId = null, + ) { + } + constructor( isValid: Boolean, state: IapkitPurchaseState, @@ -3633,6 +3709,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( isValid = isValid, state = state, store = store, + explicitStoreId = null, ) { this.clientPayload = clientPayload this.productId = productId @@ -3649,21 +3726,80 @@ public data class RequestVerifyPurchaseWithIapkitResult( isValid = isValid, state = state, store = store, + explicitStoreId = null, + ) { + this.clientPayload = clientPayload + this.productId = productId + this.environment = environment + } + + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + clientPayload: IapkitProductClientPayload? = null, + productId: String? = null, + environment: String? = null, + storeId: String, + ) : this( + isValid = isValid, + state = state, + store = store, + explicitStoreId = storeId, ) { this.clientPayload = clientPayload this.productId = productId this.environment = environment } + operator fun component1(): Boolean = isValid + operator fun component2(): IapkitPurchaseState = state + operator fun component3(): IapStore = store + + fun copy( + isValid: Boolean = this.isValid, + state: IapkitPurchaseState = this.state, + store: IapStore = this.store, + storeId: String? = null, + ): RequestVerifyPurchaseWithIapkitResult { + val resolvedStoreId = storeId?.let { resolveStoreId(store, it) } ?: if (store == this.store) this.storeId else when (store) { IapStore.Apple -> StoreIds.Apple; IapStore.Google -> StoreIds.Play; IapStore.Horizon -> StoreIds.Horizon; IapStore.Amazon -> StoreIds.Amazon; IapStore.Unknown -> throw IllegalArgumentException("RequestVerifyPurchaseWithIapkitResult with store Unknown requires an explicit valid storeId") } + return RequestVerifyPurchaseWithIapkitResult( + isValid = isValid, + state = state, + store = store, + clientPayload = this.clientPayload, + productId = this.productId, + environment = this.environment, + storeId = resolvedStoreId, + ) + } + + override fun equals(other: Any?): Boolean = other is RequestVerifyPurchaseWithIapkitResult && + isValid == other.isValid && state == other.state && store == other.store && clientPayload == other.clientPayload && productId == other.productId && environment == other.environment && storeId == other.storeId + override fun hashCode(): Int { + var result = 1 + result = 31 * result + isValid.hashCode() + result = 31 * result + state.hashCode() + result = 31 * result + store.hashCode() + result = 31 * result + (clientPayload?.hashCode() ?: 0) + result = 31 * result + (productId?.hashCode() ?: 0) + result = 31 * result + (environment?.hashCode() ?: 0) + result = 31 * result + storeId.hashCode() + return result + } + override fun toString(): String = "RequestVerifyPurchaseWithIapkitResult(isValid=$isValid, state=$state, store=$store, clientPayload=$clientPayload, productId=$productId, environment=$environment, storeId=$storeId)" + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { + val store = IapStore.fromJson(json["store"] as? String ?: "") return RequestVerifyPurchaseWithIapkitResult( isValid = json["isValid"] as? Boolean ?: false, state = (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } ?: IapkitPurchaseState.Unknown, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = store, clientPayload = (json["clientPayload"] as? Map)?.let { runCatching { IapkitProductClientPayload.fromJson(it) }.getOrNull() }, productId = json["productId"] as? String, environment = json["environment"] as? String, + storeId = resolveStoreId(store, json["storeId"]), ) } } @@ -3671,6 +3807,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun toJson(): Map = mapOf( "__typename" to "RequestVerifyPurchaseWithIapkitResult", "store" to store.toJson(), + "storeId" to storeId, "environment" to environment, "isValid" to isValid, "state" to state.toJson(), @@ -3679,6 +3816,41 @@ public data class RequestVerifyPurchaseWithIapkitResult( ) } +/** + * Store-provider contract shared by the Apple and Android native bindings. + * coreVersion names the native contract build; clientProtocolVersion names the + * Client Protocol build. Capabilities use the conformance provider profile ids. + */ +public data class StoreProviderDescriptor( + val capabilities: List, + val clientProtocolVersion: String, + val coreVersion: String, + val platform: IapPlatform, + val storeId: String +) { + + companion object { + fun fromJson(json: Map): StoreProviderDescriptor { + return StoreProviderDescriptor( + capabilities = (json["capabilities"] as? List<*>)?.mapNotNull { it as? String } ?: throw IllegalArgumentException("Missing capabilities for StoreProviderDescriptor"), + clientProtocolVersion = (json["clientProtocolVersion"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank clientProtocolVersion for StoreProviderDescriptor"), + coreVersion = (json["coreVersion"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank coreVersion for StoreProviderDescriptor"), + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: throw IllegalArgumentException("Missing required enum value for IapPlatform"), + storeId = (json["storeId"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank storeId for StoreProviderDescriptor"), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "StoreProviderDescriptor", + "capabilities" to capabilities, + "clientProtocolVersion" to clientProtocolVersion, + "coreVersion" to coreVersion, + "platform" to platform.toJson(), + "storeId" to storeId, + ) +} + public data class SubscriptionCommitmentInfoIOS( val displayPrice: String, val period: SubscriptionPeriodValueIOS, @@ -4932,10 +5104,8 @@ public data class RequestPurchaseProps( * Platform-specific purchase request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ public data class RequestPurchasePropsByPlatforms( /** @@ -5136,10 +5306,8 @@ public data class RequestSubscriptionIosProps( * Platform-specific subscription request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ public data class RequestSubscriptionPropsByPlatforms( /** @@ -5875,6 +6043,7 @@ public interface MutationResolver { suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ suspend fun requestPurchase(params: RequestPurchaseProps): RequestPurchaseResult? @@ -6281,6 +6450,7 @@ public data class MutationHandlers( val presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = null, /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ val requestPurchase: MutationRequestPurchaseHandler? = 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 1f9fa6684..64b72ac56 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 @@ -272,6 +272,7 @@ class InAppPurchaseTest { quantity = 1, signatureAndroid = "signature", store = IapStore.Google, + storeId = "play", transactionDate = 1234567890.0 ) diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt index fd127194b..e94f8c62c 100644 --- a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt @@ -194,6 +194,7 @@ class VerificationTest { quantity = 1, purchaseState = PurchaseState.Purchased, store = IapStore.Apple, + storeId = "apple", isAutoRenewing = false, transactionDate = 1234567890.0, transactionId = "trans123" @@ -501,7 +502,8 @@ class VerificationTest { val result = RequestVerifyPurchaseWithIapkitResult( isValid = true, state = IapkitPurchaseState.Entitled, - store = IapStore.Apple + store = IapStore.Apple, + storeId = "apple", ) assertTrue(result.isValid) assertEquals(IapkitPurchaseState.Entitled, result.state) @@ -513,7 +515,8 @@ class VerificationTest { val result = RequestVerifyPurchaseWithIapkitResult( isValid = false, state = IapkitPurchaseState.Expired, - store = IapStore.Google + store = IapStore.Google, + storeId = "play", ) assertFalse(result.isValid) assertEquals(IapkitPurchaseState.Expired, result.state) @@ -525,7 +528,8 @@ class VerificationTest { val result = RequestVerifyPurchaseWithIapkitResult( isValid = false, state = IapkitPurchaseState.Inauthentic, - store = IapStore.Apple + store = IapStore.Apple, + storeId = "apple", ) assertFalse(result.isValid) assertEquals(IapkitPurchaseState.Inauthentic, result.state) @@ -536,7 +540,8 @@ class VerificationTest { val result = RequestVerifyPurchaseWithIapkitResult( isValid = true, state = IapkitPurchaseState.ReadyToConsume, - store = IapStore.Google + store = IapStore.Google, + storeId = "play", ) assertTrue(result.isValid) assertEquals(IapkitPurchaseState.ReadyToConsume, result.state) @@ -547,7 +552,8 @@ class VerificationTest { val result = RequestVerifyPurchaseWithIapkitResult( isValid = true, state = IapkitPurchaseState.PendingAcknowledgment, - store = IapStore.Google + store = IapStore.Google, + storeId = "play", ) assertTrue(result.isValid) assertEquals(IapkitPurchaseState.PendingAcknowledgment, result.state) @@ -558,7 +564,8 @@ class VerificationTest { val result = RequestVerifyPurchaseWithIapkitResult( isValid = true, state = IapkitPurchaseState.Entitled, - store = IapStore.Apple + store = IapStore.Apple, + storeId = "apple", ) val json = result.toJson() assertEquals(true, json["isValid"]) @@ -619,7 +626,8 @@ class VerificationTest { iapkit = RequestVerifyPurchaseWithIapkitResult( isValid = true, state = IapkitPurchaseState.Entitled, - store = IapStore.Apple + store = IapStore.Apple, + storeId = "apple", ) ) assertEquals(PurchaseVerificationProvider.Iapkit, result.provider) @@ -646,7 +654,8 @@ class VerificationTest { iapkit = RequestVerifyPurchaseWithIapkitResult( isValid = true, state = IapkitPurchaseState.Entitled, - store = IapStore.Apple + store = IapStore.Apple, + storeId = "apple", ) ) val json = result.toJson() @@ -762,7 +771,8 @@ class VerificationTest { isValid = true, productId = "premium.monthly", state = IapkitPurchaseState.Entitled, - store = IapStore.Apple + store = IapStore.Apple, + storeId = "apple", ) val json = original.toJson() val restored = RequestVerifyPurchaseWithIapkitResult.fromJson(json) 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 6d157c0af..ea29dc735 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 @@ -37,6 +37,10 @@ internal fun requireStorefront(value: String?): String = ) ) +// Native matches an empty filter against nothing; empty means "all" here. +internal fun List?.normalizeSubscriptionIdsIOS(): List? = + if (isNullOrEmpty()) null else this + private const val IOS_CONNECTION_OPERATION_TIMEOUT_MS = 15_000L internal class IosConnectionLifecycle( @@ -194,7 +198,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { override fun getVersion(): String = kmpIapVersionString("iOS") - override fun getStore(): Store = Store.APP_STORE + override fun getStore(): Store = if (openIapModule.storeId == "apple") Store.APP_STORE else Store.UNKNOWN override suspend fun canMakePayments(): Boolean = withContext(Dispatchers.Main) { isConnected } @@ -289,7 +293,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return@suspendCancellableCoroutine } - openIapModule.requestPurchaseWithPayload(params.toJson().toObjCMap()) { result, error -> + openIapModule.requestPurchaseWithJSON(params.toJson().toJsonStringIOS()) { result, error -> if (error != null) { continuation.resumeWithExceptionIfActive(error.toPurchaseException()) } else if (result != null) { @@ -316,27 +320,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } - private fun Map.toObjCMap(): Map = - entries.associate { (key, value) -> key to value.toObjCValue() } - - private fun Any?.toObjCValue(): Any = when (val value = this) { - null -> NSNull() - is Map<*, *> -> NSMutableDictionary().apply { - value.forEach { (key, nestedValue) -> - if (key != null) { - setObject( - nestedValue.toObjCValue(), - NSString.create(string = key.toString()) - ) - } - } - } - is List<*> -> NSMutableArray().apply { - value.forEach { addObject(it.toObjCValue()) } - } - else -> value - } - /** * Restore non-consumable and active subscription purchases. * @@ -368,12 +351,9 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { override suspend fun finishTransaction(purchase: PurchaseInput, isConsumable: Boolean?): Unit = suspendCancellableCoroutine { continuation -> // No first-purchase notice on iOS: nothing reliably tells a debug build of the host app. - val transactionId = purchase.id - val productId = purchase.productId - - openIapModule.finishTransactionWithPurchaseId( - transactionId, - productId = productId, + val payload = purchase.toJson().toJsonStringIOS() + openIapModule.finishTransactionWithPurchaseJSON( + payload, isConsumable = isConsumable ?: false ) { error -> if (error != null) { @@ -670,10 +650,11 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { */ override suspend fun getActiveSubscriptions(subscriptionIds: List?): List = suspendCancellableCoroutine { continuation -> - openIapModule.getActiveSubscriptionsWithCompletion { result, error -> + val ids = subscriptionIds.normalizeSubscriptionIdsIOS() + openIapModule.getActiveSubscriptionsWithSubscriptionIds(ids) { result, error -> if (error != null) { continuation.resumeWithExceptionIfActive(error.toPurchaseException()) - return@getActiveSubscriptionsWithCompletion + return@getActiveSubscriptionsWithSubscriptionIds } try { @@ -756,31 +737,13 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { */ override suspend fun hasActiveSubscriptions(subscriptionIds: List?): Boolean = suspendCancellableCoroutine { continuation -> - if (subscriptionIds.isNullOrEmpty()) { - openIapModule.hasActiveSubscriptionsWithCompletion { hasActive, error -> - if (error != null) { - continuation.resumeWithExceptionIfActive(error.toPurchaseException()) - return@hasActiveSubscriptionsWithCompletion - } - - continuation.resumeIfActive(hasActive) - } - return@suspendCancellableCoroutine - } - - openIapModule.getActiveSubscriptionsWithCompletion { result, error -> + val ids = subscriptionIds.normalizeSubscriptionIdsIOS() + openIapModule.hasActiveSubscriptionsWithSubscriptionIds(ids) { hasActive, error -> if (error != null) { continuation.resumeWithExceptionIfActive(error.toPurchaseException()) - return@getActiveSubscriptionsWithCompletion - } - - try { - continuation.resumeIfActive( - decodeActiveSubscriptionListPayloadIOS(result, subscriptionIds).isNotEmpty() - ) - } catch (decodeError: Exception) { - continuation.resumeWithExceptionIfActive(decodeError) + return@hasActiveSubscriptionsWithSubscriptionIds } + continuation.resumeIfActive(hasActive) } } @@ -1039,14 +1002,8 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { includeClientPayload = iapkit.includeClientPayload == true ) { result, error -> if (error != null) { - val nsError = error continuation.resumeWithExceptionIfActive( - PurchaseException( - PurchaseError( - code = ErrorCode.PurchaseVerificationFailed, - message = nsError.localizedDescription - ) - ) + error.toPurchaseException(ErrorCode.PurchaseVerificationFailed) ) return@verifyPurchaseWithProviderObjCWithProvider } @@ -1076,7 +1033,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { IapkitPurchaseState.fromJson(stateString) }.getOrDefault(IapkitPurchaseState.Unknown) val store = IapStore.fromJson(storeString) - if (store != IapStore.Apple) { + if (store !in setOf(IapStore.Apple, IapStore.Unknown)) { throw IllegalArgumentException("IAPKit result store mismatch: $storeString") } val productId = when (val rawProductId = map["productId"]) { @@ -1108,14 +1065,15 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { ) } } - val iapkitResult = RequestVerifyPurchaseWithIapkitResult( - clientPayload = clientPayload, - environment = environment, - isValid = isValid, - productId = productId, - state = state, - store = store - ) + val iapkitResult = RequestVerifyPurchaseWithIapkitResult.fromJson(mapOf( + "clientPayload" to clientPayload?.toJson(), + "environment" to environment, + "isValid" to isValid, + "productId" to productId, + "state" to state.rawValue, + "store" to store.rawValue, + "storeId" to map["storeId"], + )) continuation.resumeIfActive( VerifyPurchaseWithProviderResult( diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt index 308443021..ea84b87bc 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt @@ -62,7 +62,7 @@ internal fun normalizePurchasePayloadIOS(data: Any?): Map? { internal fun decodePurchasePayloadIOS(data: Any?): PurchaseIOS? { val normalized = normalizePurchasePayloadIOS(data) ?: return null - if (normalized["store"] != "apple") return null + if (normalized["store"] !in setOf("apple", "unknown")) return null if ((normalized["productId"] as? String).isNullOrBlank()) return null if ( (normalized["id"] as? String).isNullOrBlank() || @@ -117,7 +117,7 @@ internal fun decodePurchaseListPayloadIOS(data: Any?): List { } val normalized = normalizePurchasePayloadIOS(bridgeMap)!! if ( - normalized["store"] != "apple" || + normalized["store"] !in setOf("apple", "unknown") || (normalized["productId"] as? String).isNullOrBlank() || (normalized["id"] as? String).isNullOrBlank() || (normalized["transactionId"] as? String).isNullOrBlank() || @@ -182,7 +182,7 @@ private fun Map.hasNativePurchaseQuantityIOS(): Boolean = this["quantity"] is Number || this["quantityIOS"] is Number private fun Map.hasNativePurchaseIdentityIOS(): Boolean = - (this["store"] as? String)?.equals("apple", ignoreCase = true) == true && + (this["store"] as? String)?.lowercase() in setOf("apple", "unknown") && !(this["productId"] as? String).isNullOrBlank() && !(this["id"] as? String).isNullOrBlank() && !(this["transactionId"] as? String).isNullOrBlank() diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/PurchaseJsonIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/PurchaseJsonIOS.kt new file mode 100644 index 000000000..319b216ce --- /dev/null +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/PurchaseJsonIOS.kt @@ -0,0 +1,23 @@ +package io.github.hyochan.kmpiap + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +internal fun Map.toJsonStringIOS(): String = + JsonObject(mapValues { it.value.toJsonElementIOS() }).toString() + +private fun Any?.toJsonElementIOS(): JsonElement = when (val value = this) { + null -> JsonNull + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + is List<*> -> JsonArray(value.map { it.toJsonElementIOS() }) + is Map<*, *> -> JsonObject(value.entries.associate { (key, nested) -> + require(key is String) { "JSON object keys must be strings" } + key to nested.toJsonElementIOS() + }) + else -> throw IllegalArgumentException("Unsupported JSON value") +} diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt index fee090f09..90225a576 100644 --- a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt @@ -63,6 +63,23 @@ class IosErrorMappingTest { assertNull(exception.error.debugMessage) } + @Test + fun testVerificationRetainsUnsupportedProviderCode() { + val error = NSError.errorWithDomain( + domain = "OpenIAP", + code = -1, + userInfo = mapOf( + "code" to "feature-not-supported", + "message" to "Use the vendor backend", + "debugMessage" to "Provider does not offer managed verification" + ) + ) + val exception = error.toPurchaseException(ErrorCode.PurchaseVerificationFailed) + assertEquals(ErrorCode.FeatureNotSupported, exception.error.code) + assertEquals("Use the vendor backend", exception.error.message) + assertEquals("Provider does not offer managed verification", exception.error.debugMessage) + } + @Test fun testMissingStorefrontDoesNotInventCountry() { val exception = assertFailsWith { diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt index 66213dd6a..db40e7b78 100644 --- a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt @@ -29,6 +29,20 @@ class ProductPayloadNormalizerTestIOS { "transactionDate" to 1_700_000_000_000.0, ) + @Test + fun `community iOS purchases preserve opaque identity and reject invalid ids`() { + val purchase = validPurchase("opaque-txn").toMutableMap().apply { + this["store"] = "unknown" + this["storeId"] = "community-fixture" + } + assertEquals("community-fixture", decodePurchaseListPayloadIOS(listOf(purchase)).single().storeId) + assertEquals("opaque-txn", decodePurchasePayloadIOS(purchase)?.transactionId) + for (id in listOf(null, "", "apple", "play", "Bad id")) { + purchase["storeId"] = id + assertFailsWith { decodePurchaseListPayloadIOS(listOf(purchase)) } + } + } + @Test fun `strict purchase list preserves explicit empty result`() { assertEquals(emptyList(), decodePurchaseListPayloadIOS(emptyList())) diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/PurchaseJsonBridgeTestIOS.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/PurchaseJsonBridgeTestIOS.kt new file mode 100644 index 000000000..8d7783eb1 --- /dev/null +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/PurchaseJsonBridgeTestIOS.kt @@ -0,0 +1,31 @@ +package io.github.hyochan.kmpiap + +import io.github.hyochan.kmpiap.openiap.IapStore +import io.github.hyochan.kmpiap.openiap.PurchaseIOS +import io.github.hyochan.kmpiap.openiap.PurchaseState +import kotlin.test.Test +import kotlin.test.assertContains + +class PurchaseJsonBridgeTestIOS { + @Test + fun `purchase JSON keeps booleans as JSON booleans across Objective-C`() { + for (renewing in listOf(false, true)) { + val purchase = PurchaseIOS( + id = "opaque-transaction", + transactionId = "opaque-transaction", + productId = "fixture.product", + purchaseState = PurchaseState.Purchased, + isAutoRenewing = renewing, + quantity = 1, + store = IapStore.Unknown, + storeId = "community-fixture", + transactionDate = 1_700_000_000_000.0, + ) + val payload = purchase.toJson() + mapOf("nested" to listOf(mapOf("flag" to renewing))) + val json = payload.toJsonStringIOS() + assertContains(json, "\"isAutoRenewing\":$renewing") + assertContains(json, "\"flag\":$renewing") + assertContains(json, "\"storeId\":\"community-fixture\"") + } + } +} diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/SubscriptionIdsNormalizerTestIOS.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/SubscriptionIdsNormalizerTestIOS.kt new file mode 100644 index 000000000..fa24bb6cb --- /dev/null +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/SubscriptionIdsNormalizerTestIOS.kt @@ -0,0 +1,20 @@ +package io.github.hyochan.kmpiap + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SubscriptionIdsNormalizerTestIOS { + @Test + fun `empty subscription list normalizes to null`() { + assertNull(emptyList().normalizeSubscriptionIdsIOS()) + val missing: List? = null + assertNull(missing.normalizeSubscriptionIdsIOS()) + } + + @Test + fun `non-empty subscription list passes through`() { + val ids = listOf("premium.monthly", "premium.yearly") + assertEquals(ids, ids.normalizeSubscriptionIdsIOS()) + } +} diff --git a/libraries/kmp-iap/settings.gradle.kts b/libraries/kmp-iap/settings.gradle.kts index 19d560136..932c33103 100644 --- a/libraries/kmp-iap/settings.gradle.kts +++ b/libraries/kmp-iap/settings.gradle.kts @@ -35,6 +35,7 @@ include(":example:composeApp") includeBuild("../../packages/google") { dependencySubstitution { + substitute(module("io.github.hyochan.openiap:openiap-core")).using(project(":openiap-core")) substitute(module("io.github.hyochan.openiap:openiap-google")).using(project(":openiap")) substitute(module("io.github.hyochan.openiap:openiap-google-horizon")).using(project(":openiap")) substitute(module("io.github.hyochan.openiap:openiap-google-amazon")).using(project(":openiap")) diff --git a/libraries/maui-iap/android/openiap/build.gradle.kts b/libraries/maui-iap/android/openiap/build.gradle.kts index 4dba2ecb1..4be167d0d 100644 --- a/libraries/maui-iap/android/openiap/build.gradle.kts +++ b/libraries/maui-iap/android/openiap/build.gradle.kts @@ -91,6 +91,8 @@ android { missingDimensionStrategy("platform", openIapStore) } + testOptions { unitTests.isIncludeAndroidResources = true } + buildTypes { release { isMinifyEnabled = false @@ -111,7 +113,10 @@ kotlin { } dependencies { - compileOnly("io.github.hyochan.openiap:$openIapGoogleArtifact:$openIapGoogleVersion") + compileOnly("io.github.hyochan.openiap:openiap-core:$openIapGoogleVersion") + testImplementation("io.github.hyochan.openiap:openiap-core:$openIapGoogleVersion") + testImplementation("junit:junit:${readGoogleDependencyVersion("junit:junit")}") + testImplementation("org.robolectric:robolectric:${readGoogleDependencyVersion("org.robolectric:robolectric")}") implementation("androidx.core:core:$googleCoreVersion") implementation("com.google.code.gson:gson:$gsonVersion") 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 17931c296..034c00996 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 @@ -18,7 +18,7 @@ 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 +import dev.hyo.openiap.OpenIapProvider import dev.hyo.openiap.ProductRequest import dev.hyo.openiap.PurchaseInput import dev.hyo.openiap.PurchaseOptions @@ -34,6 +34,7 @@ import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener +import dev.hyo.openiap.utils.redeemOfferCode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -41,36 +42,18 @@ import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong -/** - * Java-friendly facade over [OpenIapModule] for the .NET MAUI binding (`OpenIap.Maui`). - * - * The C# Xamarin.Android binding generator chokes on Kotlin `suspend` functions, - * Kotlin lambda types (`Function1`/`Function2`), default-arg synthetic methods (`*$default`), - * and on the transitive `com.android.billingclient` dependency that `OpenIapModule` - * pulls in. This class re-exposes the full Android-side resolver surface as plain Java - * methods that take JSON strings, return JSON strings via a `Callback`, and emit - * listener events through a `EventCallback` interface — keeping `OpenIapModule` - * itself untouched so the existing kmp-iap / RN / Flutter / Godot wiring is unaffected. - * - * Mirrors the role of `packages/apple/Sources/OpenIapModule+ObjC.swift` on iOS. - */ +/** Java-friendly JSON facade over the selected provider for the .NET binding. */ class OpenIapMauiModule(private val context: Context) { - private val module = OpenIapModule(context) + private val provider = runCatching { OpenIapProvider.create(context) } + private val module get() = provider.getOrThrow() private val gson = Gson() private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - // OpenIapModule.currentActivityRef is private; mirror it here so the Android-only - // mutations such as launchExternalLinkAndroid that need an Activity can - // throw a typed error if the host app forgot to call setActivity(). + // Android UI mutations need the host activity. private var currentActivity: Activity? = null - /** - * Token-keyed listener registry. Listener objects come back from - * [OpenIapModule.addPurchaseUpdateListener] etc. as opaque references; the C# - * binding holds the [Long] token instead of the listener instance so it - * doesn't have to cross the JNI boundary with a generic type parameter. - */ + // JNI callers hold tokens rather than Kotlin listener objects. private val listeners = ConcurrentHashMap() private val nextListenerToken = AtomicLong(1) @@ -97,7 +80,7 @@ class OpenIapMauiModule(private val context: Context) { fun setActivity(activity: Activity?) { currentActivity = activity - module.setActivity(activity) + provider.getOrNull()?.setActivity(activity) } // ----------------------------------------------------------------- @@ -125,8 +108,6 @@ class OpenIapMauiModule(private val context: Context) { fun getAvailablePurchases(optionsJson: String?, callback: ResultCallback) = run(callback) { val options = optionsJson?.let { PurchaseOptions.fromJson(parseMap(it)) } - // Module's getAvailablePurchases is the suspend handler; the protocol exposes - // the typealias as `val getAvailablePurchases: QueryGetAvailablePurchasesHandler` val purchases = module.getAvailablePurchases(options) encodePurchases(purchases) } @@ -225,29 +206,25 @@ class OpenIapMauiModule(private val context: Context) { fun showBillingProgramInformationDialogAndroid(paramsJson: String, callback: ResultCallback) = run(callback) { val params = BillingProgramInformationDialogParamsAndroid.fromJson(parseMap(paramsJson)) ?: throw badInput("BillingProgramInformationDialogParamsAndroid") - val activity = currentActivityOrThrow("showBillingProgramInformationDialogAndroid") + val activity = currentActivityOrThrow() 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") + val activity = currentActivityOrThrow() gson.toJson(module.showInAppMessages(activity, params).toJson()) } fun launchExternalLinkAndroid(paramsJson: String, callback: ResultCallback) = run(callback) { val params = LaunchExternalLinkParamsAndroid.fromJson(parseMap(paramsJson)) ?: throw badInput("LaunchExternalLinkParamsAndroid") - val activity = currentActivityOrThrow("launchExternalLinkAndroid") + val activity = currentActivityOrThrow() wrapBool(module.launchExternalLink(activity, params)) } fun openRedeemOfferCodeAndroid(callback: ResultCallback) = run(callback) { - val handler = module.mutationHandlers.openRedeemOfferCodeAndroid - ?: throw OpenIapError.FeatureNotSupported( - "openRedeemOfferCodeAndroid is not wired for this store", - ) - wrapBool(handler()) + wrapBool(redeemOfferCode(module, currentActivity)) } // ----------------------------------------------------------------- @@ -265,9 +242,9 @@ class OpenIapMauiModule(private val context: Context) { val listener = OpenIapPurchaseUpdateListener { purchase -> callback.onEvent(gson.toJson(purchase.toJson())) } - module.addPurchaseUpdateListener(listener) + provider.getOrNull()?.addPurchaseUpdateListener(listener) return register(object : ListenerEntry { - override fun unregister() = module.removePurchaseUpdateListener(listener) + override fun unregister() { provider.getOrNull()?.removePurchaseUpdateListener(listener) } }) } @@ -275,9 +252,9 @@ class OpenIapMauiModule(private val context: Context) { val listener = OpenIapPurchaseErrorListener { error -> callback.onEvent(gson.toJson(encodeError(error))) } - module.addPurchaseErrorListener(listener) + provider.getOrNull()?.addPurchaseErrorListener(listener) return register(object : ListenerEntry { - override fun unregister() = module.removePurchaseErrorListener(listener) + override fun unregister() { provider.getOrNull()?.removePurchaseErrorListener(listener) } }) } @@ -285,9 +262,9 @@ class OpenIapMauiModule(private val context: Context) { val listener = OpenIapSubscriptionBillingIssueListener { purchase -> callback.onEvent(gson.toJson(purchase.toJson())) } - module.addSubscriptionBillingIssueListener(listener) + provider.getOrNull()?.addSubscriptionBillingIssueListener(listener) return register(object : ListenerEntry { - override fun unregister() = module.removeSubscriptionBillingIssueListener(listener) + override fun unregister() { provider.getOrNull()?.removeSubscriptionBillingIssueListener(listener) } }) } @@ -295,9 +272,9 @@ class OpenIapMauiModule(private val context: Context) { val listener = OpenIapUserChoiceBillingListener { details -> callback.onEvent(gson.toJson(details.toJson())) } - module.addUserChoiceBillingListener(listener) + provider.getOrNull()?.addUserChoiceBillingListener(listener) return register(object : ListenerEntry { - override fun unregister() = module.removeUserChoiceBillingListener(listener) + override fun unregister() { provider.getOrNull()?.removeUserChoiceBillingListener(listener) } }) } @@ -305,9 +282,9 @@ class OpenIapMauiModule(private val context: Context) { val listener = OpenIapDeveloperProvidedBillingListener { details -> callback.onEvent(gson.toJson(details.toJson())) } - module.addDeveloperProvidedBillingListener(listener) + provider.getOrNull()?.addDeveloperProvidedBillingListener(listener) return register(object : ListenerEntry { - override fun unregister() = module.removeDeveloperProvidedBillingListener(listener) + override fun unregister() { provider.getOrNull()?.removeDeveloperProvidedBillingListener(listener) } }) } @@ -331,17 +308,8 @@ class OpenIapMauiModule(private val context: Context) { return token } - private fun currentActivityOrThrow(@Suppress("UNUSED_PARAMETER") api: String): Activity { - // setActivity(...) is called by the host MAUI app on lifecycle events; - // when it hasn't fired yet, surfacing OpenIapError.MissingCurrentActivity makes - // the error path obvious instead of a generic NullPointerException. - return currentActivity ?: throw OpenIapError.MissingCurrentActivity - } + private fun currentActivityOrThrow(): Activity = currentActivity ?: throw OpenIapError.MissingCurrentActivity - /** - * Run a suspending block off the main thread and route its result/error through [callback]. - * The block returns the JSON-encoded payload (or null for void). - */ private inline fun run(callback: ResultCallback, crossinline block: suspend () -> String?) { scope.launch { try { diff --git a/libraries/maui-iap/android/openiap/src/test/java/dev/hyo/openiap/maui/ProviderConfigurationTest.kt b/libraries/maui-iap/android/openiap/src/test/java/dev/hyo/openiap/maui/ProviderConfigurationTest.kt new file mode 100644 index 000000000..00b8924e7 --- /dev/null +++ b/libraries/maui-iap/android/openiap/src/test/java/dev/hyo/openiap/maui/ProviderConfigurationTest.kt @@ -0,0 +1,47 @@ +package dev.hyo.openiap.maui + +import android.os.Bundle +import android.os.Looper +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProviderConfigurationTest { + @Test fun `invalid provider is reported through init rather than JNI construction`() { + val context = RuntimeEnvironment.getApplication() + val info = context.packageManager.getApplicationInfo(context.packageName, 0) + info.metaData = Bundle() + shadowOf(context.packageManager).installPackage( + android.content.pm.PackageInfo().apply { + packageName = context.packageName + applicationInfo = info + }, + ) + val module = OpenIapMauiModule(context) + module.setActivity(null) + val token = module.addPurchaseUpdatedListener { fail("Invalid provider must not emit a purchase") } + var error: String? = null + var responses = 0 + module.initConnection(null) { result, errorJson -> + assertNull(result) + error = errorJson + responses++ + } + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, responses) + val payload = JsonParser.parseString(error).asJsonObject + assertEquals("developer-error", payload["code"].asString) + assertTrue(payload["message"].asString.contains("provider", ignoreCase = true)) + module.removeListener(token) + } +} diff --git a/libraries/maui-iap/android/settings.gradle.kts b/libraries/maui-iap/android/settings.gradle.kts index edadc02d6..6f11bd7de 100644 --- a/libraries/maui-iap/android/settings.gradle.kts +++ b/libraries/maui-iap/android/settings.gradle.kts @@ -48,6 +48,7 @@ include(":openiap") includeBuild("../../../packages/google") { dependencySubstitution { + substitute(module("io.github.hyochan.openiap:openiap-core")).using(project(":openiap-core")) substitute(module("io.github.hyochan.openiap:openiap-google")).using(project(":openiap")) substitute(module("io.github.hyochan.openiap:openiap-google-horizon")).using(project(":openiap")) substitute(module("io.github.hyochan.openiap:openiap-google-amazon")).using(project(":openiap")) diff --git a/libraries/maui-iap/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj b/libraries/maui-iap/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj index 3106b1782..c1779967d 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj @@ -54,6 +54,7 @@ reference does not, and the store AARs come from packages/google. --> $(MSBuildThisFileDirectory)..\..\..\..\packages\google\openiap\build\outputs\aar\ + $(MSBuildThisFileDirectory)..\..\..\..\packages\google\core\build\outputs\aar\ 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 707f0ed06..ba4048a63 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 @@ -5,10 +5,7 @@ namespace OpenIap.Maui.Example.Pages; -// Mirrors libraries/expo-iap/example/app/purchase-flow.tsx — full purchase -// flow demo including verification picker, storefront probe, products list, -// available-purchases list, latest-result panel, and the iOS-only -// App Transaction probe. +// Verify purchases before finishing them, including recovered iOS transactions. public partial class PurchaseFlowPage : ContentPage { private enum VerificationMethod { Ignore, Local, IapkitLocal, Iapkit } @@ -54,7 +51,7 @@ public PurchaseFlowPage() protected override async void OnAppearing() { base.OnAppearing(); - _purchaseSub ??= OpenIapClient.Instance.PurchaseUpdated.Subscribe(p => MainThread.BeginInvokeOnMainThread(() => OnPurchase(p))); + _purchaseSub ??= OpenIapClient.Instance.PurchaseUpdated.Subscribe(p => MainThread.BeginInvokeOnMainThread(async () => await OnPurchaseAsync(p))); _errorSub ??= OpenIapClient.Instance.PurchaseError.Subscribe(err => MainThread.BeginInvokeOnMainThread(() => OnPurchaseError(err))); await ConnectAndFetchAsync(); } @@ -79,15 +76,28 @@ private async Task ConnectAndFetchAsync() ContentScroll.IsVisible = true; LoadingView.IsVisible = false; - if (_didFetch) return; - _didFetch = true; - - ProductsCountLabel.Text = "Loading products..."; - RenderPurchases(); - - _ = RefreshStorefrontAsync(showAlert: false); - _ = LoadProductsAsync(); - _ = RefreshAvailablePurchasesAsync(showAlert: false); + if (!_didFetch) + { + _didFetch = true; + ProductsCountLabel.Text = "Loading products..."; + RenderPurchases(); + _ = RefreshStorefrontAsync(showAlert: false); + _ = LoadProductsAsync(); + _ = RefreshAvailablePurchasesAsync(showAlert: false); + } +#if IOS || MACCATALYST + try + { + var pending = await ((QueryResolver)OpenIapClient.Instance) + .GetPendingTransactionsIOSAsync().WaitAsync(TimeSpan.FromSeconds(15)); + foreach (var purchase in pending) await OnPurchaseAsync(purchase); + } + catch (OpenIapException ex) when (ex.Error.Code == ErrorCode.FeatureNotSupported) { } + catch (Exception ex) + { + UpdateResult($"Pending purchase recovery failed: {ErrorUtils.ExtractErrorMessage(ex)}"); + } +#endif } catch (Exception ex) { @@ -364,11 +374,11 @@ private async Task ObservePurchaseRequestAsync(Task requ var result = await requestTask; if (result is RequestPurchaseResultPurchase { Value: { } purchase }) { - MainThread.BeginInvokeOnMainThread(() => + MainThread.BeginInvokeOnMainThread(async () => { if (_isProcessing) { - OnPurchase(purchase); + await OnPurchaseAsync(purchase); } }); } @@ -395,8 +405,7 @@ private async Task ObservePurchaseRequestAsync(Task requ } } - // Called on the main thread; subscribes already marshal to UI. - private async void OnPurchase(Purchase purchase) + private async Task OnPurchaseAsync(Purchase purchase) { var common = (PurchaseCommon)purchase; // The request result and PurchaseUpdated both deliver the same purchase. diff --git a/libraries/maui-iap/scripts/verify-community-provider.sh b/libraries/maui-iap/scripts/verify-community-provider.sh new file mode 100755 index 000000000..de6eba9b2 --- /dev/null +++ b/libraries/maui-iap/scripts/verify-community-provider.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +maui_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +repo_root=$(cd "$maui_root/../.." && pwd) +google_root="$repo_root/packages/google" +fixture="$google_root/compatibility/community-provider" +example="$maui_root/example/OpenIap.Maui.Example/OpenIap.Maui.Example.csproj" +workspace=$(mktemp -d) +server_pid="" +cleanup() { + if [[ -n "$server_pid" ]]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + rm -rf "$workspace" +} +trap cleanup EXIT +repository="$workspace/maven" +core_version=$(node -p "require(process.argv[1]).google" "$repo_root/openiap-versions.json") +suite_version=$(node --input-type=module -e "import {SUITE_VERSION} from '$repo_root/packages/conformance/src/spec/suite-version.mjs'; console.log(SUITE_VERSION)") + +"$google_root/gradlew" -p "$google_root" :openiap-core:publishToMavenLocal :openiap-conformance:publishToMavenLocal -Dmaven.repo.local="$repository" > "$workspace/core.log" 2>&1 +"$google_root/gradlew" -p "$fixture" :vendor-sdk:publishVendorPublicationToMavenLocal :provider:publishFixturePublicationToMavenLocal \ + -Dmaven.repo.local="$repository" -PopenIapRepository="$repository" -PopenIapVersion="$core_version" -PconformanceVersion="$suite_version" > "$workspace/provider.log" 2>&1 +python3 - "$repository" "$workspace/port" > "$workspace/http.log" 2>&1 <<'PY' & +import base64, functools, hashlib, http.server, pathlib, sys +handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=sys.argv[1]) +for _ in range(100): + server = http.server.HTTPServer(('127.0.0.1', 0), handler) + repository_url = f'http://127.0.0.1:{server.server_port}' + cache_name = base64.b64encode(hashlib.sha256(repository_url.encode()).digest()).decode() + # .NET Android 36.1.69 uses this Base64 name as an unescaped cache path. + if '/' not in cache_name: break + server.server_close() +else: raise SystemExit('Could not select a filesystem-safe Maven fixture URL') +pathlib.Path(sys.argv[2]).write_text(str(server.server_port)) +server.serve_forever() +PY +server_pid=$! +python3 - "$workspace/port" <<'PY' +import pathlib, sys, time +path = pathlib.Path(sys.argv[1]) +for _ in range(100): + if path.exists(): break + time.sleep(0.05) +else: raise SystemExit('Fixture Maven server did not start') +PY +port=$(cat "$workspace/port") +cat > "$workspace/repository.targets" < + + + + +TARGETS + +dotnet restore "$example" -p:TargetFrameworks=net10.0-android --nologo > "$workspace/restore.log" 2>&1 +args=(-nologo -p:TargetFramework=net10.0-android "-p:IntermediateOutputPath=$workspace/obj/" "-p:MavenCacheDirectory=$workspace/maven-cache" -p:OpenIapStore=community-fixture "-p:CustomAfterMicrosoftCommonTargets=$workspace/repository.targets") +dotnet msbuild "$example" "${args[@]}" -p:OpenIapProvider=community.fixture:provider:1.0.0 \ + -t:_CategorizeAndroidLibraries -getItem:AndroidLibrary,AndroidIgnoredJavaDependency > "$workspace/resolved.json" 2>&1 +python3 - "$workspace/resolved.json" <<'PY' +import json, pathlib, sys, zipfile +raw = pathlib.Path(sys.argv[1]).read_text() +items = json.loads(raw[raw.index('{'):])['Items']['AndroidLibrary'] +coordinates = [item.get('JavaArtifact', '') for item in items] +assert 'community.fixture:provider:1.0.0' in coordinates, coordinates +assert 'community.fixture:vendor-sdk:1.0.0' in coordinates, coordinates +assert 'org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1' in coordinates, coordinates +assert 'org.jetbrains.kotlinx:kotlinx-datetime:0.7.1' not in coordinates, coordinates +assert any(c.startswith('androidx.compose.runtime:runtime-android:') for c in coordinates), coordinates +redirects = [item['Identity'] for item in json.loads(raw[raw.index('{'):])['Items']['AndroidIgnoredJavaDependency']] +assert 'org.jetbrains.kotlinx:kotlinx-datetime:0.7.1' in redirects, redirects +date_jar = next(item['Identity'] for item in items if item.get('JavaArtifact') == 'org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1') +with zipfile.ZipFile(date_jar) as archive: + assert 'kotlinx/datetime/LocalDate.class' in archive.namelist() +assert sum(c.startswith('io.github.hyochan.openiap:openiap-core:') for c in coordinates) == 1, coordinates +assert not any(c.startswith(('org.jetbrains.kotlin:kotlin-stdlib:', 'org.jetbrains.kotlinx:kotlinx-coroutines-')) for c in coordinates), coordinates +assert not any(c.startswith(('com.android.billingclient:', 'com.meta.horizon:', 'com.amazon.device:')) for c in coordinates), coordinates +print('MAUI community runtime closure includes the vendor SDK, one core, and no duplicate Kotlin or official billing SDK.') +PY + +if ! dotnet msbuild "$example" "${args[@]}" -p:OpenIapProvider=community.fixture:provider:1.0.0 -p:FixtureVendorVersion=1.0.0 \ + -t:_CategorizeAndroidLibraries -getItem:AndroidLibrary > "$workspace/direct-vendor.json" 2>&1; then + cat "$workspace/direct-vendor.json" >&2 + exit 1 +fi +python3 - "$workspace/direct-vendor.json" <<'PY' +import json, pathlib, sys +raw = pathlib.Path(sys.argv[1]).read_text() +items = json.loads(raw[raw.index('{'):])['Items']['AndroidLibrary'] +assert sum(item.get('JavaArtifact') == 'community.fixture:vendor-sdk:1.0.0' for item in items) == 1, items +print('MAUI links a directly declared and transitive vendor SDK exactly once.') +PY +if dotnet msbuild "$example" "${args[@]}" -p:OpenIapProvider=community.fixture:provider:1.0.0 -p:FixtureVendorVersion=0.9.0 -t:_CategorizeAndroidLibraries > "$workspace/vendor-conflict.log" 2>&1; then + echo 'MAUI replaced an incompatible directly supplied vendor SDK.' >&2 + exit 1 +fi +grep -Fq 'vendor-sdk resolves 1.0.0, but the app provides 0.9.0' "$workspace/vendor-conflict.log" +echo 'MAUI rejects a provider requiring a newer vendor SDK than the app supplies.' + +bad="$repository/community/fixture/incompatible/1.0.0" +mkdir -p "$bad" +cp "$repository/community/fixture/provider/1.0.0/provider-1.0.0.aar" "$bad/incompatible-1.0.0.aar" +cat > "$bad/incompatible-1.0.0.pom" <<'POM' +4.0.0community.fixtureincompatible1.0.0aario.github.hyochan.openiapopeniap-core999.0.0 +POM +if dotnet msbuild "$example" "${args[@]}" -p:OpenIapProvider=community.fixture:incompatible:1.0.0 -t:_CategorizeAndroidLibraries > "$workspace/incompatible.log" 2>&1; then + echo 'MAUI linked a provider requiring an incompatible core.' >&2 + exit 1 +fi +if ! grep -Eq 'Cannot find a version.*openiap-core|Could not (resolve|find).*openiap-core|openiap-core resolves 999' "$workspace/incompatible.log"; then + cat "$workspace/incompatible.log" >&2 + exit 1 +fi +echo 'MAUI rejects a provider whose required core conflicts with the bundled core.' diff --git a/libraries/maui-iap/scripts/verify-store-selection.sh b/libraries/maui-iap/scripts/verify-store-selection.sh index 6f19f7a20..afba928ee 100755 --- a/libraries/maui-iap/scripts/verify-store-selection.sh +++ b/libraries/maui-iap/scripts/verify-store-selection.sh @@ -90,6 +90,15 @@ run "a value that names no store fails" "fail:OpenIapStore='bogus' is not a sto run "and names the alias when it was set" "fail:OpenIapAndroidStore='bogus' is not a store" $link -p:OpenIapAndroidStore=bogus run "even beside a valid OpenIapStore" "fail:OpenIapAndroidStore='bogus' is not a store" $link -p:OpenIapStore=play -p:OpenIapAndroidStore=bogus +echo "community provider" +run "external pair selects the neutral core" "community-fixture:" $link -p:OpenIapStore=community-fixture -p:OpenIapProvider=community.fixture:provider:1.0.0 +# Gradle lower-cases the id before matching, so a cased community id selects too. +run "a community id ignores case" "community-fixture:" $link -p:OpenIapStore=Community-Fixture -p:OpenIapProvider=community.fixture:provider:1.0.0 +run "external id needs coordinates" "fail:OpenIapStore='community-fixture' is not a store" $link -p:OpenIapStore=community-fixture +run "official selection rejects an external pair" "fail:OpenIapProvider requires a community OpenIapStore id" $link -p:OpenIapStore=play -p:OpenIapProvider=community.fixture:provider:1.0.0 +run "provider version must be fixed" "fail:OpenIapProvider must be fixed group:artifact:version coordinates" $link -p:OpenIapStore=community-fixture -p:OpenIapProvider=community.fixture:provider:+ +run "official coordinates cannot pose as external" "fail:OpenIapProvider must name a community artifact" $link -p:OpenIapStore=community-fixture -p:OpenIapProvider=io.github.hyochan.openiap:openiap-google:3.6.2 + echo "connected device" with_device QUEST1 "feature:oculus.hardware.standalone_vr" Oculus run "a Quest links Horizon" "$horizon" $link "$adb" @@ -115,6 +124,7 @@ echo "dependency verification" run "Play's Maven SDKs verify" "$play" _CategorizeAndroidLibraries -p:OpenIapStore=play run "Horizon's Maven SDKs verify" "$horizon" _CategorizeAndroidLibraries -p:OpenIapStore=horizon run "Amazon's Maven SDKs verify" "$amazon" _CategorizeAndroidLibraries -p:OpenIapStore=amazon +bash "$maui_root/scripts/verify-community-provider.sh" # --apk also builds the whole example per store and checks what its APK links: # manifest merge, D8 and packaging are past the stage the cases above reach. diff --git a/libraries/maui-iap/src/Directory.Build.props b/libraries/maui-iap/src/Directory.Build.props index b9ab6312b..ba19bec87 100644 --- a/libraries/maui-iap/src/Directory.Build.props +++ b/libraries/maui-iap/src/Directory.Build.props @@ -12,6 +12,8 @@ 2.14.0.1 1.13.0.1 1.6.0.1 + 1.11.4 + 1.5.1.3 1.9.0 1.9.0 2.11.0.1 diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs index 929b572b1..3f149113b 100644 --- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs +++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs @@ -91,6 +91,10 @@ void RequestSubscriptionExtended( [Async] void FinishTransaction(string purchaseId, string productId, bool isConsumable, Action completion); + [Export("finishTransactionWithPurchaseJSON:isConsumable:completion:")] + [Async] + void FinishTransactionWithPurchaseJSON(string purchaseJSON, bool isConsumable, Action completion); + [Export("getPendingTransactionsIOSWithCompletion:")] [Async] void GetPendingTransactionsIOS(Action completion); @@ -133,10 +137,18 @@ void VerifyPurchaseWithProviderPayload( [Async] void GetActiveSubscriptions(Action completion); + [Export("getActiveSubscriptionsWithSubscriptionIds:completion:")] + [Async] + void GetActiveSubscriptions([NullAllowed] string[] subscriptionIds, Action completion); + [Export("hasActiveSubscriptionsWithCompletion:")] [Async] void HasActiveSubscriptions(Action completion); + [Export("hasActiveSubscriptionsWithSubscriptionIds:completion:")] + [Async] + void HasActiveSubscriptions([NullAllowed] string[] subscriptionIds, Action completion); + [Export("subscriptionStatusIOSWithSku:completion:")] [Async] void SubscriptionStatusIOS(string sku, Action completion); diff --git a/libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs b/libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs index 5f92cac8d..4c8e4d73c 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using OpenIap; namespace OpenIap.Maui; @@ -81,16 +82,36 @@ private static void ValidatePurchase(T decoded, string operation, int index) purchase.Ids?.Any(string.IsNullOrWhiteSpace) == true || decoded is PurchaseIOS ios && (string.IsNullOrWhiteSpace(ios.TransactionId) || - ios.Store is not IapStore.Apple) || + !ValidIOSStore(ios)) || decoded is PurchaseAndroid android && - android.Store != IapStore.Google && - android.Store != IapStore.Amazon && - android.Store != IapStore.Horizon) + !ValidAndroidStore(android)) { throw Malformed(operation, $"returned a purchase with invalid identity at index {index}"); } } + private static bool ValidIOSStore(PurchaseIOS purchase) => purchase.Store switch + { + IapStore.Apple => purchase.StoreId == StoreIds.Apple, + IapStore.Unknown => ValidCommunityStoreId(purchase.StoreId), + _ => false, + }; + + private static bool ValidCommunityStoreId(string? id) => id is not null && + Regex.IsMatch(id, "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") && + id is not ("auto" or "none" or "unknown" or "google") && + id != StoreIds.Apple && id != StoreIds.Play && + id != StoreIds.Horizon && id != StoreIds.Amazon; + + private static bool ValidAndroidStore(PurchaseAndroid purchase) => purchase.Store switch + { + IapStore.Google => purchase.StoreId == StoreIds.Play, + IapStore.Horizon => purchase.StoreId == StoreIds.Horizon, + IapStore.Amazon => purchase.StoreId == StoreIds.Amazon, + IapStore.Unknown => ValidCommunityStoreId(purchase.StoreId), + _ => false, + }; + private static OpenIapException Malformed(string operation, string reason) => OpenIapErrorMapper.Wrap( ErrorCode.BillingResponseJsonParseError, diff --git a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj index 6beb84a36..a1b623527 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj +++ b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj @@ -91,6 +91,9 @@ OpenIAP implementation itself uses the merged base APIs. --> + + + @@ -120,10 +123,14 @@ <_OpenIapGoogleAarOutputs>..\..\..\..\packages\google\openiap\build\outputs\aar\ + + + + + <_OpenIapMauiPropsImported>true + 3.6.2 9.1.0 3.0.9 2.0.0 diff --git a/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets index e2601b87d..a3532e88f 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets +++ b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets @@ -1,4 +1,27 @@ + + + + + + + + { + var fields = line.Split('\t'); + if (fields.Length != 2) throw new System.InvalidOperationException("Invalid provider artifact manifest"); + var item = new Microsoft.Build.Utilities.TaskItem(fields[0]); + item.SetMetadata("JavaArtifact", fields[1]); + item.SetMetadata("Bind", "false"); + return (Microsoft.Build.Framework.ITaskItem)item; + }).ToArray(); + Redirects = System.IO.File.ReadAllLines(ManifestFile + ".redirects") + .Select(line => (Microsoft.Build.Framework.ITaskItem)new Microsoft.Build.Utilities.TaskItem(line)).ToArray(); + ]]> + + + <_PropertyCacheItems Include="OpenIapLinkedStore=$(OpenIapLinkedStore)" /> + <_PropertyCacheItems Include="OpenIapProvider=$(OpenIapProvider)" /> @@ -151,4 +190,33 @@ + + + + <_OpenIapResolverDirectory>$(MSBuildThisFileDirectory)provider-resolver + <_OpenIapWrapperJar>$(_OpenIapResolverDirectory)/gradle/wrapper/gradle-wrapper.jar + <_OpenIapWrapperJar Condition="!Exists('$(_OpenIapWrapperJar)')">$(MSBuildThisFileDirectory)../../../../../packages/google/gradle/wrapper/gradle-wrapper.jar + <_OpenIapResolverOutput>$([System.IO.Path]::GetFullPath('$(IntermediateOutputPath)openiap-provider')) + <_OpenIapResolverManifest>$(_OpenIapResolverOutput)/artifacts.tsv + <_OpenIapJavaExe>$(_JavaSdkDirectory)/bin/java + <_OpenIapJavaExe Condition="$([MSBuild]::IsOSPlatform('Windows'))">$(_OpenIapJavaExe).exe + + + <_OpenIapSuppliedJava Include="@(AndroidLibrary->'%(JavaArtifact)');@(PackageReference->'%(JavaArtifact)');@(ProjectReference->'%(JavaArtifact)');@(AndroidIgnoredJavaDependency)" /> + <_OpenIapSuppliedJava Include="@(AndroidMavenLibrary->'%(Identity):%(Version)')" Condition="'%(AndroidMavenLibrary.JavaArtifact)' == ''" /> + <_OpenIapSuppliedJava Include="@(AndroidMavenLibrary->'%(JavaArtifact)')" Condition="'%(AndroidMavenLibrary.JavaArtifact)' != ''" /> + + + + + + + + + + diff --git a/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/.gitignore b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/.gitignore new file mode 100644 index 000000000..3bb19ffd7 --- /dev/null +++ b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/.gitignore @@ -0,0 +1 @@ +.gradle/ diff --git a/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/build.gradle b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/build.gradle new file mode 100644 index 000000000..f4cfdbd54 --- /dev/null +++ b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/build.gradle @@ -0,0 +1,143 @@ +import groovy.json.JsonSlurper +import groovy.xml.XmlSlurper +import org.gradle.api.attributes.Category +import org.gradle.api.attributes.Usage +import org.gradle.api.attributes.java.TargetJvmEnvironment + +plugins { id 'java-base' } + +String required(String name) { + def value = System.getenv(name) + if (!value) throw new GradleException("openiap: missing resolver input ${name}") + return value +} + +def supplied = [:] +def ownedClosures = [] as Set +def credit = { String coordinate -> + def parts = coordinate.trim().split(':') + if (parts.length != 3) throw new GradleException("openiap: invalid supplied JavaArtifact ${coordinate}") + def module = "${parts[0]}:${parts[1]}".toString() + if (supplied[module] && supplied[module] != parts[2]) { + throw new GradleException("openiap: conflicting supplied versions for ${module}") + } + supplied[module] = parts[2] +} +new File(required('OPENIAP_SUPPLIED')).eachLine { line -> + line.split('[,;]').findAll { it.trim() }.each { credit(it) } +} + +def assets = new JsonSlurper().parse(new File(required('OPENIAP_ASSETS'))) +def target = assets.targets[required('OPENIAP_TARGET')] +if (target == null) throw new GradleException('openiap: Android NuGet target is missing; restore the app first') +target.findAll { name, library -> library.type == 'package' }.each { name, library -> + def entry = assets.libraries[name] + def nuspec = entry.files.find { it.toLowerCase().endsWith('.nuspec') } + if (nuspec) { + def file = assets.packageFolders.keySet().collect { new File(it, "${entry.path}/${nuspec}") }.find { it.isFile() } + if (!file) throw new GradleException("openiap: restored NuGet manifest is missing for ${name}") + def tags = new XmlSlurper(false, false).parse(file).metadata.tags.text() + def matches = tags =~ /artifact(?:_versioned)?=([^:\s;,]+:[^:\s;,]+:[^:\s;,]+)/ + matches.each { match -> + credit(match[1]) + ownedClosures.add(match[1].split(':').take(2).join(':')) + } + } +} + +repositories { + def extra = System.getenv('OPENIAP_REPOSITORIES_FILE') + if (extra) new File(extra).readLines().findAll { it.trim() }.each { repository -> maven { url = uri(repository.trim()) } } + google() + mavenCentral() + toList().each { repository -> + if (repository instanceof MavenArtifactRepository) { + repository.content { + ownedClosures.each { module -> + def parts = module.split(':') + excludeModule(parts[0], parts[1]) + } + } + maven { + url = repository.url + content { + ownedClosures.each { module -> + def parts = module.split(':') + includeModule(parts[0], parts[1]) + } + } + // NuGet already selected the runtime variant for these modules. + metadataSources { mavenPom(); ignoreGradleMetadataRedirection() } + } + } + } +} + +def runtime = configurations.create('providerRuntime') { + canBeConsumed = false + canBeResolved = true + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY)) + attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, objects.named(TargetJvmEnvironment, TargetJvmEnvironment.ANDROID)) + } +} +dependencies { + components.all { details -> + if (ownedClosures.contains("${details.id.group}:${details.id.name}".toString())) { + // NuGet owns these runtimes and their dependency closures. + details.allVariants { withDependencies { clear() } } + } + } + add(runtime.name, required('OPENIAP_PROVIDER')) + constraints { + supplied.each { module, suppliedVersion -> + add(runtime.name, "${module}:${suppliedVersion}") + } + } +} + +tasks.register('resolveProvider') { + doLast { + runtime.incoming.resolutionResult.allComponents.each { component -> + def id = component.moduleVersion + if (id) { + def suppliedVersion = supplied["${id.group}:${id.name}".toString()] + if (suppliedVersion && id.version != suppliedVersion) { + throw new GradleException("openiap: ${id.group}:${id.name} resolves ${id.version}, but the app provides ${suppliedVersion}") + } + } + } + def artifacts = runtime.resolvedConfiguration.resolvedArtifacts + def physicalIds = artifacts.collect { it.moduleVersion.id.toString() } as Set + def output = new File(required('OPENIAP_OUTPUT')) + output.parentFile.mkdirs() + new File(output.path + '.redirects').withWriter('UTF-8') { writer -> + runtime.incoming.resolutionResult.allComponents.each { component -> + def id = component.moduleVersion + if (id && component.variants.any { it.externalVariant.isPresent() }) { + component.variants.each { variant -> + def external = variant.externalVariant.orElse(null) + while (external?.externalVariant?.isPresent()) external = external.externalVariant.get() + if (external && !physicalIds.contains(external.owner.toString())) { + throw new GradleException("openiap: metadata redirect ${id} has no linked runtime artifact") + } + } + writer.write("${id}\n") + } + } + } + output.withWriter('UTF-8') { writer -> + artifacts.sort { it.moduleVersion.id.toString() }.each { artifact -> + def id = artifact.moduleVersion.id + if (!supplied.containsKey("${id.group}:${id.name}".toString())) { + if (!(artifact.file.name.endsWith('.aar') || artifact.file.name.endsWith('.jar'))) { + throw new GradleException("openiap: unsupported runtime artifact ${id}: ${artifact.file.name}") + } + if (artifact.file.absolutePath =~ /[\t\r\n]/) throw new GradleException('openiap: artifact path contains a control character') + writer.write("${artifact.file.absolutePath}\t${id}\n") + } + } + } + } +} diff --git a/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/settings.gradle b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/settings.gradle new file mode 100644 index 000000000..973768d31 --- /dev/null +++ b/libraries/maui-iap/src/OpenIap.Maui/buildTransitive/provider-resolver/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'openiap-maui-provider-resolver' diff --git a/libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs b/libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs index 1159877b3..41bd1f4a6 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs @@ -268,6 +268,7 @@ private static Task GeneratedVerificationResultDegradesUnknownValues() "isValid": true, "state": "future-state", "store": "future-store", + "storeId": "future-store", "clientPayload": { "format": "future-format", "body": "opaque", @@ -282,6 +283,7 @@ private static Task GeneratedVerificationResultDegradesUnknownValues() "tolerant generated verification result"); AssertEqual(IapkitPurchaseState.Unknown, result.State, "unknown purchase state"); AssertEqual(IapStore.Unknown, result.Store, "unknown store"); + AssertEqual("future-store", result.StoreId, "community store identity"); AssertEqual(null, result.ClientPayload, "unknown client payload"); return Task.CompletedTask; } diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cs index c440174a4..a278711e8 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cs @@ -16,6 +16,7 @@ public class BridgePayloadDecoderTests "purchaseToken": "token-1", "quantity": 1, "store": "google", + "storeId": "play", "transactionDate": 1720000000000 } """; @@ -29,6 +30,7 @@ public class BridgePayloadDecoderTests "purchaseState": "purchased", "quantity": 1, "store": "apple", + "storeId": "apple", "transactionDate": 1720000000000, "transactionId": "transaction-1" } @@ -43,6 +45,19 @@ public class BridgePayloadDecoderTests } """; + [Fact] + public void CommunityIOSPurchasePreservesItsShapeAndOpaqueId() + { + var item = JsonNode.Parse(PurchaseIosJson)!; + item["store"] = "unknown"; + item["storeId"] = "community-fixture"; + item["transactionId"] = "opaque-txn"; + var decoded = BridgePayloadDecoder.DecodeRequiredArray(new JsonArray(item), "getAvailablePurchases"); + var purchase = Assert.IsType(Assert.Single(decoded)); + Assert.Equal("community-fixture", purchase.StoreId); + Assert.Equal("opaque-txn", purchase.TransactionId); + } + [Fact] public void ExplicitEmptyItemsIsAuthoritativeSuccess() { @@ -156,6 +171,29 @@ public void PurchaseRuntimeTypeRejectsForeignStore() Assert.Equal(ErrorCode.BillingResponseJsonParseError, androidError.Error.Code); } + [Fact] + public void CommunityPurchasePreservesItsStoreId() + { + var item = JsonNode.Parse(PurchaseJson)!; + item["store"] = "unknown"; + item["storeId"] = "community-fixture"; + var decoded = BridgePayloadDecoder.DecodeRequiredArray(new JsonArray(item), "getAvailablePurchases"); + Assert.Equal("community-fixture", Assert.Single(decoded).StoreId); + } + + [Theory] + [InlineData("")] + [InlineData("play")] + [InlineData("unknown")] + [InlineData("Bad Store")] + public void CommunityPurchaseRequiresAValidIndependentId(string storeId) + { + var item = JsonNode.Parse(PurchaseJson)!; + item["store"] = "unknown"; + item["storeId"] = storeId; + Assert.Throws(() => BridgePayloadDecoder.DecodeRequiredArray(new JsonArray(item), "getAvailablePurchases")); + } + [Fact] public void ActiveSubscriptionListFailsAtomically() { diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/FirstPurchaseNoticeTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/FirstPurchaseNoticeTests.cs index 0861a6719..42bc7dbf1 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.Tests/FirstPurchaseNoticeTests.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/FirstPurchaseNoticeTests.cs @@ -183,6 +183,7 @@ private Func Claim(bool claimed) => () => PurchaseToken = "purchase-token", Quantity = 1, Store = IapStore.Google, + StoreId = StoreIds.Play, TransactionDate = 0, }; } diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs index d14f8f7c1..9ad8f5aa8 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs @@ -10,6 +10,70 @@ namespace OpenIap.Maui.Tests; public class RecordJsonTests { + [Theory] + [InlineData("google", "play")] + [InlineData("apple", "apple")] + [InlineData("horizon", "horizon")] + [InlineData("amazon", "amazon")] + public void LegacyOfficialPurchasesInferIdentity(string store, string id) + { + var json = System.Text.Json.Nodes.JsonNode.Parse(PurchaseAndroidJson)!.AsObject(); + json.Remove("storeId"); + json["store"] = store; + var concrete = JsonSerializer.Deserialize(json.ToJsonString(), Options)!; + var union = JsonSerializer.Deserialize(json.ToJsonString(), Options)!; + Assert.Equal(id, concrete.StoreId); + Assert.Equal(id, union.StoreId); + Assert.Equal(id, JsonSerializer.Deserialize(JsonSerializer.Serialize(union, Options), Options)!.StoreId); + json.Remove("productId"); + Assert.Throws(() => JsonSerializer.Deserialize(json.ToJsonString(), Options)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("unknown")] + [InlineData("google")] + [InlineData("apple")] + [InlineData("play")] + [InlineData("amazon")] + [InlineData("horizon")] + [InlineData("auto")] + [InlineData("none")] + [InlineData("Bad id")] + [InlineData("store\n")] + public void MalformedCommunityIdentityFails(string? id) + { + var json = System.Text.Json.Nodes.JsonNode.Parse(PurchaseAndroidJson)!.AsObject(); + json["store"] = "unknown"; + json["storeId"] = id; + Assert.Throws(() => JsonSerializer.Deserialize(json.ToJsonString(), Options)); + } + + [Fact] + public void LegacyConstructedIdentityPreservesRecordEquality() + { + var result = new RequestVerifyPurchaseWithIapkitResult + { + IsValid = true, State = IapkitPurchaseState.Entitled, Store = IapStore.Google, + }; + var roundTrip = JsonSerializer.Deserialize(JsonSerializer.Serialize(result, Options), Options)!; + Assert.Equal("play", result.StoreId); + Assert.Equal(result, roundTrip); + Assert.Equal(result.GetHashCode(), roundTrip.GetHashCode()); + Assert.NotEqual(result, roundTrip with { IsValid = false }); + var legacy = new PurchaseAndroid + { + Id = "transaction", IsAutoRenewing = false, ProductId = "product", + PurchaseState = PurchaseState.Purchased, Quantity = 1, + Store = IapStore.Google, TransactionDate = 0, + }; + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(legacy, Options), Options)!; + Assert.Equal(legacy, restored); + Assert.Equal(legacy.GetHashCode(), restored.GetHashCode()); + Assert.NotEqual(legacy, restored with { ProductId = "another" }); + } + private static readonly JsonSerializerOptions Options = JsonOptions.Default; // ------------------------------------------------------------------ @@ -154,6 +218,7 @@ public void ProductAndroid_DefaultsPlatformAndTypeWhenOmitted() "quantity": 1, "signatureAndroid": "signature-abc", "store": "google", + "storeId": "play", "transactionDate": 1720000000000, "transactionId": "GPA.1234-5678", "userIdAmazon": "amazon-user-1", @@ -174,6 +239,7 @@ public void ProductAndroid_DefaultsPlatformAndTypeWhenOmitted() "purchaseToken": "amazon-receipt-1", "quantity": 1, "store": "amazon", + "storeId": "amazon", "transactionDate": 1720000000000, "transactionId": "amazon-receipt-1", "userIdAmazon": "amazon-user-1", @@ -278,6 +344,7 @@ public void ProductAndroid_DefaultsPlatformAndTypeWhenOmitted() "revocationReasonIOS": "REFUNDED", "revocationTypeIOS": "assignmentRevocation", "store": "apple", + "storeId": "apple", "storefrontCountryCodeIOS": "USA", "subscriptionGroupIdIOS": "group.premium", "transactionDate": 1720000000000, @@ -393,6 +460,7 @@ public void PurchaseIos_OmitsNullOptionalFieldsOnWrite() PurchaseState = PurchaseState.Purchased, Quantity = 1, Store = IapStore.Apple, + StoreId = StoreIds.Apple, TransactionDate = 1720000000000, TransactionId = "2000000123", }; @@ -420,6 +488,7 @@ public void Purchase_MissingRequiredPropertyThrows() "purchaseState": "purchased", "quantity": 1, "store": "google", + "storeId": "play", "transactionDate": 1720000000000 } """; @@ -507,6 +576,7 @@ public void RequestVerifyPurchaseWithIapkitResult_DeserializesClientPayload() "productId": "premium.monthly", "state": "entitled", "store": "apple", + "storeId": "apple", "clientPayload": { "format": "toml", "body": "tier = \"gold\"", @@ -549,6 +619,7 @@ public void AmazonIapkitContract_RoundTripsProductBindingAndEnvironment() ProductId = "dev.hyo.martie.10bulbs", State = IapkitPurchaseState.ReadyToConsume, Store = IapStore.Amazon, + StoreId = StoreIds.Amazon, }; var restoredProps = JsonSerializer.Deserialize( diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/SubscriptionIdsTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/SubscriptionIdsTests.cs new file mode 100644 index 000000000..841787559 --- /dev/null +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/SubscriptionIdsTests.cs @@ -0,0 +1,25 @@ +// SubscriptionIds.NormalizeForNative maps an empty or missing filter to null +// so the iOS resolvers call native unfiltered ("all") instead of matching +// nothing. Reached via InternalsVisibleTo. + +using System.Collections.Generic; +using Xunit; + +namespace OpenIap.Maui.Tests; + +public class SubscriptionIdsTests +{ + [Fact] + public void NormalizeForNative_MapsEmptyAndNullToNull() + { + Assert.Null(SubscriptionIds.NormalizeForNative(new List())); + Assert.Null(SubscriptionIds.NormalizeForNative(null)); + } + + [Fact] + public void NormalizeForNative_PassesNonEmptyThrough() + { + IReadOnlyList ids = new List { "premium.monthly", "premium.yearly" }; + Assert.Same(ids, SubscriptionIds.NormalizeForNative(ids)); + } +} diff --git a/libraries/react-native-iap/android/build.gradle b/libraries/react-native-iap/android/build.gradle index c8cba7f1c..66cc760a3 100644 --- a/libraries/react-native-iap/android/build.gradle +++ b/libraries/react-native-iap/android/build.gradle @@ -114,7 +114,8 @@ def getExtOrIntegerDefault(name) { // Store selection: explicit property > task flavor > connected debug device > play. apply from: project.file('openiap-store.gradle') -def openiapFlavor = openIapResolveStore('react-native-iap').store +def openiapSelection = openIapResolveStore('react-native-iap') +def openiapFlavor = openiapSelection.store def resolveOpenIapGoogleBuildFile() { def candidates = [ @@ -162,7 +163,7 @@ android { consumerProguardFiles 'consumer-rules.pro' // Matches the local :openiap project's platform flavor to the resolved store - missingDimensionStrategy "platform", openiapFlavor + missingDimensionStrategy "platform", openiapSelection.platform externalNativeBuild { cmake { @@ -256,18 +257,7 @@ dependencies { // Kotlin coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - // Determine which OpenIAP dependency to use - // In monorepo: use local packages/google source if available - def localGoogleProject = findProject(':openiap') - if (localGoogleProject != null) { - implementation project(':openiap') - } else if (openiapFlavor == 'amazon') { - implementation "io.github.hyochan.openiap:openiap-google-amazon:${googleVersionString}" - } else if (openiapFlavor == 'horizon') { - implementation "io.github.hyochan.openiap:openiap-google-horizon:${googleVersionString}" - } else { - implementation "io.github.hyochan.openiap:openiap-google:${googleVersionString}" - } + openIapAddStoreDependencies('implementation', googleVersionString, ':openiap') // Test dependencies testImplementation "junit:junit:$junitVersion" 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 94383a2fa..c77f17749 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 @@ -1,5 +1,6 @@ package com.margelo.nitro.iap +import android.content.Context import com.facebook.react.bridge.ReactApplicationContext import com.margelo.nitro.NitroModules import com.margelo.nitro.core.NullType @@ -11,7 +12,8 @@ import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions import dev.hyo.openiap.OpenIapError -import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProvider +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.ProductAndroid import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.ProductRequest @@ -57,6 +59,7 @@ import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.openiap.helpers.OpenIapFirstPurchaseNotice import dev.hyo.openiap.store.OpenIapStore +import dev.hyo.openiap.utils.redeemOfferCode import java.util.Locale import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers @@ -126,6 +129,24 @@ internal suspend fun endRnConnectionWithCleanup( return result } +internal suspend fun endRnConnectionOrReset( + provider: Lazy<*>, + endConnection: suspend () -> Boolean, + cleanup: () -> Unit, +): Boolean { + if (!provider.isInitialized()) { + cleanup() + return true + } + return endRnConnectionWithCleanup(endConnection, cleanup) +} + +internal fun mapEndConnectionError(error: Exception): OpenIapError = + error as? OpenIapError ?: OpenIapError.ServiceDisconnected(error.message) + +internal fun mapInitConnectionError(error: Exception, context: Context?): OpenIapError = + error as? OpenIapError ?: (context?.let { OpenIapError.InitConnection.forProvider(it) } ?: OpenIapError.InitConnection) + /** * Removes the singleton JS fan-out listener without relying on callback identity. * Nitro creates a fresh Kotlin callback wrapper for each bridge invocation, so @@ -202,7 +223,8 @@ class HybridRnIap : HybridRnIapSpec() { } // OpenIAP backend + local cache for product types - private val openIap: OpenIapModule by lazy { OpenIapModule(context) } + private val openIapLazy: Lazy = lazy { OpenIapProvider.create(context) } + private val openIap: OpenIapProtocol get() = openIapLazy.value private val productTypeBySku = mutableMapOf() // Event listeners @@ -277,7 +299,7 @@ class HybridRnIap : HybridRnIapSpec() { } catch (err: CancellationException) { throw err } catch (err: Exception) { - val error = OpenIapError.InitConnection + val error = mapInitConnectionError(err, context) val errorMessage = err.message ?: err.javaClass.name RnIapLog.failure("initConnection.setActivity", err) throw OpenIapException( @@ -380,7 +402,7 @@ class HybridRnIap : HybridRnIapSpec() { throw err } catch (err: Exception) { listenersAttached = false - val error = OpenIapError.InitConnection + val error = mapInitConnectionError(err, context) val errorMessage = err.message ?: err.javaClass.name RnIapLog.failure("initConnection.listeners", err) val wrapped = OpenIapException( @@ -414,7 +436,7 @@ class HybridRnIap : HybridRnIapSpec() { } catch (err: CancellationException) { throw err } catch (err: Exception) { - val error = OpenIapError.InitConnection + val error = mapInitConnectionError(err, context) RnIapLog.failure("initConnection.native", err) throw OpenIapException( toErrorJson( @@ -425,7 +447,7 @@ class HybridRnIap : HybridRnIapSpec() { ) } if (!ok) { - val error = OpenIapError.InitConnection + val error = OpenIapError.InitConnection.forProvider(context) RnIapLog.failure("initConnection.native", Exception(error.message)) // No override: the error names the store this binary links, // which a fixed string here would throw away. @@ -460,29 +482,39 @@ class HybridRnIap : HybridRnIapSpec() { ) val invocation = connectionLifecycleQueue.enqueueEnd(pendingInitError) { RnIapLog.payload("endConnection", null) - val result = endRnConnectionWithCleanup( - endConnection = { openIap.endConnection() }, - cleanup = { - productTypeBySku.clear() - isInitialized = false - // Native listener sets persist; clear only bridge callbacks and - // the pending event queues. - synchronized(purchaseUpdatedListeners) { - purchaseUpdatedListeners.clear() - pendingPurchaseUpdates.clear() - } - synchronized(purchaseErrorListeners) { - purchaseErrorListeners.clear() - pendingPurchaseErrors.clear() - } - promotedProductListenersIOS.clear() - synchronized(userChoiceBillingListenersAndroid) { userChoiceBillingListenersAndroid.clear() } - synchronized(developerProvidedBillingListenersAndroid) { developerProvidedBillingListenersAndroid.clear() } - clearSubscriptionBillingIssueListeners() - }, - ) - RnIapLog.result("endConnection", result) - result + try { + val result = endRnConnectionOrReset( + provider = openIapLazy, + endConnection = { openIap.endConnection() }, + cleanup = { + productTypeBySku.clear() + isInitialized = false + // Native listener sets persist; clear only bridge callbacks and + // the pending event queues. + synchronized(purchaseUpdatedListeners) { + purchaseUpdatedListeners.clear() + pendingPurchaseUpdates.clear() + } + synchronized(purchaseErrorListeners) { + purchaseErrorListeners.clear() + pendingPurchaseErrors.clear() + } + promotedProductListenersIOS.clear() + synchronized(userChoiceBillingListenersAndroid) { userChoiceBillingListenersAndroid.clear() } + synchronized(developerProvidedBillingListenersAndroid) { developerProvidedBillingListenersAndroid.clear() } + clearSubscriptionBillingIssueListeners() + }, + ) + RnIapLog.result("endConnection", result) + result + } catch (e: CancellationException) { + throw e + } catch (e: OpenIapException) { + throw e + } catch (e: Exception) { + RnIapLog.failure("endConnection", e) + throw OpenIapException(toErrorJson(mapEndConnectionError(e))) + } } return Promise.async { invocation.await() @@ -832,7 +864,7 @@ class HybridRnIap : HybridRnIapSpec() { purchaseToken = sub.purchaseToken.wrapVariant(), transactionDate = sub.transactionDate, // Android specific fields - autoRenewingAndroid = sub.autoRenewingAndroid.wrapVariant(), + autoRenewingAndroid = sub.autoRenewingAndroid.toNitroNullableBoolean(), basePlanIdAndroid = sub.basePlanIdAndroid.wrapVariant(), currentPlanId = sub.currentPlanId.wrapVariant(), purchaseTokenAndroid = sub.purchaseTokenAndroid.wrapVariant(), @@ -900,12 +932,22 @@ class HybridRnIap : HybridRnIapSpec() { } } + override fun restorePurchases(): Promise = Promise.async { + try { + openIap.restorePurchases() + true + } catch (error: OpenIapError) { + throw OpenIapException(toErrorJson(error), error) + } + } + // Transaction management methods (Unified) override fun finishTransaction(params: NitroFinishTransactionParams): Promise { return Promise.async { val androidParams = (params.android as? Variant_NullType_NitroFinishTransactionAndroidParams.Second)?.value ?: return@async Variant_Boolean_NitroPurchaseResult.First(true) val purchaseToken = androidParams.purchaseToken + val purchaseJson = androidParams.purchaseJson val isConsumable = androidParams.isConsumable.unwrapBool() ?: false RnIapLog.payload( @@ -917,7 +959,7 @@ class HybridRnIap : HybridRnIapSpec() { ) // Validate token early to avoid confusing native errors - if (purchaseToken.isBlank()) { + if (purchaseToken.isBlank() && purchaseJson == null) { RnIapLog.warn("finishTransaction called with missing purchaseToken") return@async Variant_Boolean_NitroPurchaseResult.Second( NitroPurchaseResult( @@ -936,7 +978,10 @@ class HybridRnIap : HybridRnIapSpec() { } try { - if (isConsumable) { + if (purchaseJson != null) { + val purchase = PurchaseAndroid.fromJson(JSONObject(purchaseJson).toPurchaseMap()) + openIap.finishTransaction(purchase, isConsumable) + } else if (isConsumable) { openIap.consumePurchaseAndroid(purchaseToken) } else { openIap.acknowledgePurchaseAndroid(purchaseToken) @@ -1368,6 +1413,8 @@ class HybridRnIap : HybridRnIapSpec() { currentPlanId = purchase.currentPlanId.wrapVariant(), ids = purchase.ids.wrapVariant(), store = mapIapStore(purchase.store), + storeId = purchase.storeId, + platform = IapPlatform.ANDROID, quantity = purchase.quantity.toDouble(), purchaseState = mapPurchaseState(purchase.purchaseState), isAutoRenewing = purchase.isAutoRenewing, @@ -1405,7 +1452,7 @@ class HybridRnIap : HybridRnIapSpec() { purchaseTokenAndroid = androidPurchase?.purchaseToken.wrapVariant(), dataAndroid = androidPurchase?.dataAndroid.wrapVariant(), signatureAndroid = androidPurchase?.signatureAndroid.wrapVariant(), - autoRenewingAndroid = androidPurchase?.autoRenewingAndroid.wrapVariant(), + autoRenewingAndroid = androidPurchase?.autoRenewingAndroid.toNitroNullableBoolean(), purchaseStateAndroid = purchaseStateAndroidNumeric.wrapVariant(), isAcknowledgedAndroid = androidPurchase?.isAcknowledgedAndroid.wrapVariant(), packageNameAndroid = androidPurchase?.packageNameAndroid.wrapVariant(), @@ -1455,6 +1502,10 @@ class HybridRnIap : HybridRnIapSpec() { packageNameAndroid = options.packageNameAndroid.unwrapString() ).let { openIap.deepLinkToSubscriptions(it) } RnIapLog.result("deepLinkToSubscriptionsAndroid", true) + } catch (e: CancellationException) { + throw e + } catch (e: OpenIapError) { + throw OpenIapException(toErrorJson(e), e) } catch (e: Exception) { RnIapLog.failure("deepLinkToSubscriptionsAndroid", e) throw e @@ -1622,6 +1673,7 @@ class HybridRnIap : HybridRnIapSpec() { RnIapLog.failure("verifyPurchase", e) throw e } catch (e: Exception) { + if (e is OpenIapError) throw OpenIapException(toErrorJson(e), e) RnIapLog.failure("verifyPurchase", e) val debugMessage = e.message val error = OpenIapError.InvalidPurchaseVerification @@ -1711,7 +1763,8 @@ class HybridRnIap : HybridRnIapSpec() { // mappers match separator-delimited spellings, so // multi-word states would otherwise degrade to UNKNOWN. state = mapIapkitPurchaseState(item.state.rawValue), - store = mapIapkitStore(item.store.rawValue) + store = mapIapkitStore(item.store.rawValue), + storeId = item.storeId ) } @@ -1731,6 +1784,8 @@ class HybridRnIap : HybridRnIapSpec() { } catch (e: CancellationException) { throw e } catch (e: Exception) { + if (e is OpenIapException) throw e + if (e is OpenIapError) throw OpenIapException(toErrorJson(e), e) RnIapLog.failure("verifyPurchaseWithProvider", e) val error = OpenIapError.VerificationFailed throw OpenIapException( @@ -2125,12 +2180,9 @@ class HybridRnIap : HybridRnIapSpec() { return Promise.async { RnIapLog.payload("openRedeemOfferCodeAndroid", null) try { - withContext(Dispatchers.Main) { - runCatching { context.currentActivity }.getOrNull()?.let(openIap::setActivity) + val result = withContext(Dispatchers.Main) { + redeemOfferCode(openIap, runCatching { context.currentActivity }.getOrNull()) } - val handler = openIap.mutationHandlers.openRedeemOfferCodeAndroid - ?: throw OpenIapError.FeatureNotSupported() - val result = handler() RnIapLog.result("openRedeemOfferCodeAndroid", result) result } catch (err: CancellationException) { @@ -2427,3 +2479,13 @@ class HybridRnIap : HybridRnIapSpec() { ) } } + +private fun JSONObject.toPurchaseMap(): Map = keys().asSequence().associateWith { key -> + fun value(raw: Any?): Any? = when (raw) { + JSONObject.NULL -> null + is JSONObject -> raw.toPurchaseMap() + is JSONArray -> (0 until raw.length()).map { value(raw.opt(it)) } + else -> raw + } + value(opt(key)) +} diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/PurchaseSerialization.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/PurchaseSerialization.kt new file mode 100644 index 000000000..4a0855ab3 --- /dev/null +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/PurchaseSerialization.kt @@ -0,0 +1,8 @@ +package com.margelo.nitro.iap + +import com.margelo.nitro.core.NullType + +// Kotlin null becomes JS undefined; an explicit Nitro null preserves unknown status. +internal fun Boolean?.toNitroNullableBoolean(): Variant_NullType_Boolean = + this?.let { Variant_NullType_Boolean.Second(it) } + ?: Variant_NullType_Boolean.First(NullType.NULL) diff --git a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ConnectionLifecycleQueueTest.kt b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ConnectionLifecycleQueueTest.kt index fb36cec15..247d8b862 100644 --- a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ConnectionLifecycleQueueTest.kt +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ConnectionLifecycleQueueTest.kt @@ -40,6 +40,53 @@ class ConnectionLifecycleQueueTest { assertFalse(cleanedUp.get()) } + @Test(timeout = 5_000) + fun `end without a provider resets state without touching a provider`() = runBlocking { + val cleanedUp = AtomicBoolean(false) + val provider = lazy { throw AssertionError("must not create a provider") } + + val result = endRnConnectionOrReset( + provider = provider, + endConnection = { throw AssertionError("must not touch a provider") }, + cleanup = { cleanedUp.set(true) }, + ) + + assertTrue(result) + assertTrue(cleanedUp.get()) + assertFalse(provider.isInitialized()) + } + + @Test(timeout = 5_000) + fun `end with a provider delegates to cleanup after success`() = runBlocking { + val cleanedUp = AtomicBoolean(false) + + val result = endRnConnectionOrReset( + provider = lazyOf(Unit), + endConnection = { true }, + cleanup = { cleanedUp.set(true) }, + ) + + assertTrue(result) + assertTrue(cleanedUp.get()) + } + + @Test(timeout = 5_000) + fun `end failure with a provider preserves connection state`() = runBlocking { + val failure = IllegalStateException("teardown failed") + val cleanedUp = AtomicBoolean(false) + + val result = runCatching { + endRnConnectionOrReset( + provider = lazyOf(Unit), + endConnection = { throw failure }, + cleanup = { cleanedUp.set(true) }, + ) + } + + assertEquals(failure, result.exceptionOrNull()) + assertFalse(cleanedUp.get()) + } + @Test(timeout = 5_000) fun `later end invalidates a pending init even when end awaits first`() = runBlocking { diff --git a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/InitConnectionStoreHintTest.kt b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/InitConnectionStoreHintTest.kt new file mode 100644 index 000000000..65669d085 --- /dev/null +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/InitConnectionStoreHintTest.kt @@ -0,0 +1,31 @@ +package com.margelo.nitro.iap + +import dev.hyo.openiap.OpenIapError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the init-failure contract HybridRnIap relies on: a failed + * initConnection on a non-Play build names the linked store. + */ +class InitConnectionStoreHintTest { + @Test + fun `non-Play stores are named in the init-failure message`() { + for (store in listOf("horizon", "amazon")) { + val error = OpenIapError.InitConnection.forStore(store) + + assertEquals(OpenIapError.InitConnection.CODE, error.code) + assertTrue(error.message.contains(store)) + } + } + + @Test + fun `Play and unknown stores keep the plain message`() { + for (store in listOf("play", null)) { + val error = OpenIapError.InitConnection.forStore(store) + + assertEquals(OpenIapError.InitConnection.MESSAGE, error.message) + } + } +} diff --git a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/OpenIapErrorMappingTest.kt b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/OpenIapErrorMappingTest.kt index 3f6fb6085..fe3cbb401 100644 --- a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/OpenIapErrorMappingTest.kt +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/OpenIapErrorMappingTest.kt @@ -1,6 +1,7 @@ package com.margelo.nitro.iap import dev.hyo.openiap.OpenIapError +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame @@ -31,4 +32,33 @@ class OpenIapErrorMappingTest { assertFalse(rejectDisconnectedPurchase(isInitialized = true) { emitted = it }) assertNull(emitted) } + + @Test + fun `endConnection preserves a provider configuration error`() { + val failure = OpenIapError.ProviderConfiguration("No Android store provider registered.") + + assertSame(failure, mapEndConnectionError(failure)) + assertEquals("developer-error", failure.code) + } + + @Test + fun `endConnection maps an unexpected failure to service disconnected`() { + val mapped = mapEndConnectionError(IllegalStateException("teardown failed")) + + assertEquals(OpenIapError.ServiceDisconnected.CODE, mapped.code) + assertEquals("teardown failed", mapped.debugMessage) + } + + @Test + fun `initConnection preserves a provider configuration error`() { + val failure = OpenIapError.ProviderConfiguration("No Android store provider registered.") + + assertSame(failure, mapInitConnectionError(failure, null)) + assertEquals("developer-error", failure.code) + } + + @Test + fun `initConnection maps an unexpected failure to init connection`() { + assertSame(OpenIapError.InitConnection, mapInitConnectionError(IllegalStateException("boom"), null)) + } } diff --git a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/PurchaseSerializationTest.kt b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/PurchaseSerializationTest.kt new file mode 100644 index 000000000..bbd55a6cb --- /dev/null +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/PurchaseSerializationTest.kt @@ -0,0 +1,27 @@ +package com.margelo.nitro.iap + +import com.margelo.nitro.core.NullType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class PurchaseSerializationTest { + @Test + fun `unknown renewal uses the native null variant rather than an absent value`() { + val renewal: Boolean? = null + val variant = renewal.toNitroNullableBoolean() + + assertNotNull(variant) + assertEquals(NullType.NULL, variant.asFirstOrNull()) + } + + @Test + fun `disabled renewal remains a present false value`() { + assertEquals(false, false.toNitroNullableBoolean().asSecondOrNull()) + } + + @Test + fun `enabled renewal remains a present true value`() { + assertEquals(true, true.toNitroNullableBoolean().asSecondOrNull()) + } +} diff --git a/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx b/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx index 6322ab98a..5a91c0ce6 100644 --- a/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/OfferCode.test.tsx @@ -106,7 +106,7 @@ describe('OfferCode Screen', () => { Platform.OS = 'android'; const {getByText} = await render(); - expect(getByText('🎁 Open Play Store')).toBeTruthy(); + expect(getByText('🎁 Open Redemption Flow')).toBeTruthy(); }); it('handles Android offer code redemption button press', async () => { @@ -115,14 +115,14 @@ describe('OfferCode Screen', () => { const {getByText} = await render(); - const redeemButton = getByText('🎁 Open Play Store'); + const redeemButton = getByText('🎁 Open Redemption Flow'); await fireEvent.press(redeemButton); await waitFor(() => { expect(mockOpenRedeemOfferCode).toHaveBeenCalled(); expect(Alert.alert).toHaveBeenCalledWith( 'Redemption Requested', - 'Google Play opens its redeem page; stores without one open nothing. Refresh available purchases after redeeming.', + 'The selected store opens its redemption flow when supported. Refresh available purchases after redeeming.', ); }); }); @@ -135,7 +135,7 @@ describe('OfferCode Screen', () => { const {getByText} = await render(); - const redeemButton = getByText('🎁 Open Play Store'); + const redeemButton = getByText('🎁 Open Redemption Flow'); await fireEvent.press(redeemButton); await waitFor(() => { diff --git a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx index 75c8d22b6..4921186f4 100644 --- a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx @@ -222,6 +222,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.10bulbs', state: 'entitled', store: 'google', + storeId: 'play', }, }), ); @@ -320,6 +321,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }); @@ -349,6 +351,7 @@ describe('PurchaseFlow Screen', () => { purchaseToken: 'google-token-restored-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const verificationResult: VerifyPurchaseWithProviderResult = { @@ -358,6 +361,7 @@ describe('PurchaseFlow Screen', () => { productId: restoredPurchase.productId, state: 'ready-to-consume', store: 'google', + storeId: 'play', }, }; const finishTransaction = jest.fn( @@ -426,6 +430,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.10bulbs', state: 'consumed', store: 'google', + storeId: 'play', }, }, }, @@ -440,6 +445,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.30bulbs', state: 'entitled', store: 'google', + storeId: 'play', }, }, }, @@ -455,6 +461,7 @@ describe('PurchaseFlow Screen', () => { purchaseToken: `google-token-restored-${result.iapkit?.productId}`, quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const finishTransaction = jest.fn( @@ -498,6 +505,7 @@ describe('PurchaseFlow Screen', () => { purchaseToken: 'google-token-reconnect-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const verificationResult: VerifyPurchaseWithProviderResult = { @@ -507,6 +515,7 @@ describe('PurchaseFlow Screen', () => { productId: purchase.productId, state: 'entitled', store: 'google', + storeId: 'play', }, }; const finishTransaction = jest.fn( @@ -627,6 +636,7 @@ describe('PurchaseFlow Screen', () => { purchaseToken: 'google-token-unmount-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const finishTransaction = jest.fn( @@ -682,6 +692,7 @@ describe('PurchaseFlow Screen', () => { productId: purchase.productId, state: 'entitled', store: 'google', + storeId: 'play', }, }); await pendingProcessing; @@ -704,6 +715,7 @@ describe('PurchaseFlow Screen', () => { purchaseToken: 'google-token-remount-pending-finish-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; let resolveFinish!: () => void; @@ -724,6 +736,7 @@ describe('PurchaseFlow Screen', () => { productId: purchase.productId, state: 'entitled', store: 'google', + storeId: 'play', }, }), ); @@ -792,6 +805,7 @@ describe('PurchaseFlow Screen', () => { purchaseToken: 'google-token-remount-after-owner-finish-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; let resolveFinish!: () => void; @@ -813,6 +827,7 @@ describe('PurchaseFlow Screen', () => { productId: purchase.productId, state: 'entitled', store: 'google', + storeId: 'play', }, }), ); @@ -894,6 +909,7 @@ describe('PurchaseFlow Screen', () => { purchaseToken: 'google-token-race-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), purchaseState: 'purchased', }; @@ -948,6 +964,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.10bulbs', state: 'entitled', store: 'google', + storeId: 'play', }, }); await purchasePromise; @@ -967,6 +984,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'google-token-invalid-1', store: 'google', + storeId: 'play', transactionDate: Date.now(), purchaseState: 'purchased', }; @@ -980,6 +998,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.10bulbs', state: 'consumed', store: 'google', + storeId: 'play', }, }), ); @@ -1029,6 +1048,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'device-apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }); @@ -1070,6 +1090,7 @@ describe('PurchaseFlow Screen', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'hosted-apple-jws', store: 'apple', + storeId: 'apple', transactionDate: Date.now(), purchaseState: 'purchased', }); 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 b4d09fa34..ebc9a01f2 100644 --- a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx @@ -62,6 +62,7 @@ describe('SubscriptionFlow Screen', () => { productId: 'dev.hyo.martie.premium', state: 'entitled', store: 'google', + storeId: 'play', }, }), ); @@ -307,6 +308,7 @@ describe('SubscriptionFlow Screen', () => { purchaseToken: 'google-sub-token-restored-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const finishTransaction = jest.fn( @@ -320,6 +322,7 @@ describe('SubscriptionFlow Screen', () => { productId: restoredPurchase.productId, state: 'pending-acknowledgment', store: 'google', + storeId: 'play', }, }; const refreshedVerification: VerifyPurchaseWithProviderResult = { @@ -329,6 +332,7 @@ describe('SubscriptionFlow Screen', () => { productId: restoredPurchase.productId, state: 'entitled', store: 'google', + storeId: 'play', }, }; let verificationCallCount = 0; @@ -392,6 +396,7 @@ describe('SubscriptionFlow Screen', () => { productId: 'dev.hyo.martie.premium', state: 'expired', store: 'google', + storeId: 'play', }, } satisfies VerifyPurchaseWithProviderResult, }, @@ -406,6 +411,7 @@ describe('SubscriptionFlow Screen', () => { productId: 'dev.hyo.martie.premium_year', state: 'entitled', store: 'google', + storeId: 'play', }, } satisfies VerifyPurchaseWithProviderResult, }, @@ -421,6 +427,7 @@ describe('SubscriptionFlow Screen', () => { purchaseToken: `google-sub-token-restored-${caseName}`, quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const finishTransaction = jest.fn( @@ -464,6 +471,7 @@ describe('SubscriptionFlow Screen', () => { purchaseToken: 'google-sub-token-restored-unmount-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const finishTransaction = jest.fn( @@ -491,6 +499,7 @@ describe('SubscriptionFlow Screen', () => { productId: restoredPurchase.productId, state: 'entitled', store: 'google', + storeId: 'play', }, }); }, @@ -523,6 +532,7 @@ describe('SubscriptionFlow Screen', () => { productId: restoredPurchase.productId, state: 'pending-acknowledgment', store: 'google', + storeId: 'play', }, }); await Promise.resolve(); @@ -554,6 +564,7 @@ describe('SubscriptionFlow Screen', () => { purchaseToken: 'google-sub-token-cancel-retry-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const finishTransaction = jest.fn( @@ -571,6 +582,7 @@ describe('SubscriptionFlow Screen', () => { productId: purchase.productId, state: 'pending-acknowledgment', store: 'google', + storeId: 'play', }, }), ); @@ -632,6 +644,7 @@ describe('SubscriptionFlow Screen', () => { purchaseToken: 'google-sub-token-remount-pending-finish-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; let resolveFinish!: () => void; @@ -652,6 +665,7 @@ describe('SubscriptionFlow Screen', () => { productId: purchase.productId, state: 'entitled', store: 'google', + storeId: 'play', }, }), ); @@ -724,6 +738,7 @@ describe('SubscriptionFlow Screen', () => { purchaseToken: 'google-sub-token-remount-after-owner-finish-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; let resolveFinish!: () => void; @@ -745,6 +760,7 @@ describe('SubscriptionFlow Screen', () => { productId: purchase.productId, state: 'pending-acknowledgment', store: 'google', + storeId: 'play', }, }), ); @@ -833,6 +849,7 @@ describe('SubscriptionFlow Screen', () => { purchaseToken: 'google-sub-token-race-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), purchaseState: 'purchased', }; @@ -887,6 +904,7 @@ describe('SubscriptionFlow Screen', () => { productId: 'dev.hyo.martie.premium', state: 'entitled', store: 'google', + storeId: 'play', }, }); await purchasePromise; @@ -913,6 +931,7 @@ describe('SubscriptionFlow Screen', () => { purchaseToken: 'google-sub-token-reconnect-1', quantity: 1, store: 'google', + storeId: 'play', transactionDate: Date.now(), }; const finishTransaction = jest.fn( @@ -933,6 +952,7 @@ describe('SubscriptionFlow Screen', () => { productId: purchase.productId, state: 'entitled', store: 'google', + storeId: 'play', }, }; let verificationCallCount = 0; @@ -1001,6 +1021,7 @@ describe('SubscriptionFlow Screen', () => { productId: purchase.productId, state: 'pending-acknowledgment', store: 'google', + storeId: 'play', }, }); await purchasePromise; @@ -1065,6 +1086,7 @@ describe('SubscriptionFlow Screen', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'google-sub-token-invalid-1', store: 'google', + storeId: 'play', transactionDate: Date.now(), purchaseState: 'purchased', }; @@ -1078,6 +1100,7 @@ describe('SubscriptionFlow Screen', () => { productId: 'dev.hyo.martie.premium', state: 'consumed', store: 'google', + storeId: 'play', }, }), ); diff --git a/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts index 0050fefef..cecb3e42a 100644 --- a/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts +++ b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts @@ -41,6 +41,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'token-1', store: 'google', + storeId: 'play', } as unknown as Purchase, 'token-1', 'test-api-key', @@ -62,6 +63,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.premium', purchaseToken: 'purchase-id-1', store: 'horizon', + storeId: 'horizon', } as unknown as Purchase, 'purchase-id-1', 'test-api-key', @@ -84,6 +86,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.monthly', purchaseToken: 'jws-1', store: 'apple', + storeId: 'apple', } as unknown as Purchase, 'jws-1', 'test-api-key', @@ -128,6 +131,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', purchaseToken: 'token-1', store: 'google', + storeId: 'play', } as unknown as Purchase, 'token-1', ' ', @@ -146,6 +150,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'entitled', store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', @@ -165,6 +170,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'consumed', store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', @@ -184,6 +190,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.30bulbs', state: 'entitled', store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', @@ -205,6 +212,7 @@ describe('Vega runtime example helpers', () => { isValid: true, state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }, }, 'dev.hyo.martie.10bulbs', @@ -225,6 +233,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }, }, 'dev.hyo.martie.10bulbs', @@ -244,6 +253,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', @@ -261,6 +271,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state: 'ready-to-consume', store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', @@ -279,6 +290,7 @@ describe('Vega runtime example helpers', () => { productId: 'dev.hyo.martie.10bulbs', state, store: 'google', + storeId: 'play', }, }, 'dev.hyo.martie.10bulbs', diff --git a/libraries/react-native-iap/example/android/settings.gradle b/libraries/react-native-iap/example/android/settings.gradle index d9d8abd9e..3d684f94d 100644 --- a/libraries/react-native-iap/example/android/settings.gradle +++ b/libraries/react-native-iap/example/android/settings.gradle @@ -25,4 +25,8 @@ def localOpenIapProject = new File(settingsDir, '../../../../packages/google/ope if (localOpenIapProject.exists()) { include ':openiap' project(':openiap').projectDir = localOpenIapProject + include ':openiap-core' + project(':openiap-core').projectDir = new File(settingsDir, '../../../../packages/google/core') + include ':openiap-conformance' + project(':openiap-conformance').projectDir = new File(settingsDir, '../../../../packages/conformance/android') } diff --git a/libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj b/libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj index fdb331a4a..41a687fd1 100644 --- a/libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj +++ b/libraries/react-native-iap/example/ios/example.xcodeproj/project.pbxproj @@ -10,13 +10,13 @@ 0C80B921A6F3F58F76C31292 /* libPods-example.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-example.a */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 2C45A4A7038ED639A0DA8047 /* SubscriptionBillingIssueReconnectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F63961E1E4779D0D0CC89E /* SubscriptionBillingIssueReconnectTests.swift */; }; - 9A1C0DE1000000000000C0DE /* ConnectOnDemandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1C0DE0000000000000C0DE /* ConnectOnDemandTests.swift */; }; 39B415FD9B823A99DF82885B /* libPods-exampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A1B53C93D4444C448EAF564 /* libPods-exampleTests.a */; }; 6CA9F570183E5756970ADA69 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 761780EF2CA45674006654EE /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EE2CA45674006654EE /* SceneDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 912AFAD7DD196565C20C2D28 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CB695629D08A69F09E048C9 /* Foundation.framework */; }; + 9A1C0DE1000000000000C0DE /* ConnectOnDemandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1C0DE0000000000000C0DE /* ConnectOnDemandTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -35,7 +35,6 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = example/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = example/PrivacyInfo.xcprivacy; sourceTree = ""; }; 15F63961E1E4779D0D0CC89E /* SubscriptionBillingIssueReconnectTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = exampleTests/SubscriptionBillingIssueReconnectTests.swift; sourceTree = ""; }; - 9A1C0DE0000000000000C0DE /* ConnectOnDemandTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = exampleTests/ConnectOnDemandTests.swift; sourceTree = ""; }; 192B71D5D16C43463188CE4C /* exampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = exampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2A1B53C93D4444C448EAF564 /* libPods-exampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-exampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4392A12AC88292D35C810B /* Pods-example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.debug.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.debug.xcconfig"; sourceTree = ""; }; @@ -46,6 +45,7 @@ 7A67B8342B38E57CDEFE6616 /* Pods-exampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-exampleTests.debug.xcconfig"; path = "Target Support Files/Pods-exampleTests/Pods-exampleTests.debug.xcconfig"; sourceTree = ""; }; 7CB695629D08A69F09E048C9 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = example/LaunchScreen.storyboard; sourceTree = ""; }; + 9A1C0DE0000000000000C0DE /* ConnectOnDemandTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = exampleTests/ConnectOnDemandTests.swift; sourceTree = ""; }; A414E86517B93E77B2E3EEA6 /* Pods-exampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-exampleTests.release.xcconfig"; path = "Target Support Files/Pods-exampleTests/Pods-exampleTests.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ diff --git a/libraries/react-native-iap/example/ios/exampleTests/ConnectOnDemandTests.swift b/libraries/react-native-iap/example/ios/exampleTests/ConnectOnDemandTests.swift index 5a2809364..76532904a 100644 --- a/libraries/react-native-iap/example/ios/exampleTests/ConnectOnDemandTests.swift +++ b/libraries/react-native-iap/example/ios/exampleTests/ConnectOnDemandTests.swift @@ -188,6 +188,47 @@ final class ConnectOnDemandTests: XCTestCase { _ = try await hybrid.endConnection().await() } + func testFailedInitPreservesProviderErrorCode() async throws { + let hybrid = HybridRnIap() + let probe = EventProbe() + let messages = EventProbe() + try hybrid.addPurchaseErrorListener { error in + probe.record(error.code) + messages.record(error.message) + } + let providerError = PurchaseError.make( + code: .developerError, + message: "Info.plist dev.hyo.openiap.PROVIDER must name a linked provider." + ) + + let connected = try await hybrid.enqueueConnectOperation { + throw providerError + }.value + + XCTAssertFalse(connected) + XCTAssertEqual(probe.events, [ErrorCode.developerError.rawValue]) + XCTAssertEqual(messages.events, [providerError.message]) + + _ = try await hybrid.endConnection().await() + } + + func testFailedInitMapsGenericErrorsToInitConnection() async throws { + let hybrid = HybridRnIap() + let probe = EventProbe() + try hybrid.addPurchaseErrorListener { error in + probe.record(error.code) + } + + let connected = try await hybrid.enqueueConnectOperation { + throw NSError(domain: "ConnectOnDemandTests", code: 1) + }.value + + XCTAssertFalse(connected) + XCTAssertEqual(probe.events, [ErrorCode.initConnection.rawValue]) + + _ = try await hybrid.endConnection().await() + } + func testOnDemandInitErrorIsOwnedByTheCallingOperation() async throws { let hybrid = HybridRnIap() let probe = EventProbe() @@ -512,6 +553,100 @@ final class ConnectOnDemandTests: XCTestCase { XCTAssertEqual(probe.events, ["native-winner", "end"]) } + func testGenericProviderThrowPreservesItsCanonicalPurchaseError() async throws { + let hybrid = HybridRnIap() + let probe = EventProbe() + try hybrid.addPurchaseErrorListener { error in probe.record(error.code) } + _ = try await hybrid.enqueueConnectOperation { true }.value + let canonicalError = PurchaseError.make( + code: .networkError, + productId: "premium", + message: "Network unavailable" + ) + let epoch = hybrid.currentConnectionEpoch() + let holder = DeliveryTaskHolder() + + for _ in 0..<2 { + let result = try await hybrid.runRequestPurchaseOperation { + let delivery = hybrid.enqueuePurchaseErrorDelivery(canonicalError, expectedEpoch: epoch) + await holder.set(delivery) + throw NSError(domain: "CommunityProvider", code: 1) + } + XCTAssertNil(result) + let heldTask = await holder.task + let delivery = try XCTUnwrap(heldTask) + _ = try await delivery.value + } + _ = try await hybrid.enqueueEndOperation { true }.value + XCTAssertEqual(probe.events, Array(repeating: ErrorCode.networkError.rawValue, count: 2)) + } + + func testGenericProviderThrowWithoutDeliveryEmitsOneFallbackError() async throws { + let hybrid = HybridRnIap() + let probe = EventProbe() + try hybrid.addPurchaseErrorListener { error in probe.record(error.code) } + _ = try await hybrid.enqueueConnectOperation { true }.value + + let result = try await hybrid.runRequestPurchaseOperation { + throw NSError(domain: "CommunityProvider", code: 1) + } + XCTAssertNil(result) + _ = try await hybrid.enqueueEndOperation { true }.value + XCTAssertEqual(probe.events, [ErrorCode.purchaseError.rawValue]) + } + + func testFallbackErrorLeavesNoSuppressionForLaterProviderError() async throws { + let hybrid = HybridRnIap() + let probe = EventProbe() + try hybrid.addPurchaseErrorListener { error in probe.record(error.code) } + _ = try await hybrid.enqueueConnectOperation { true }.value + + let result = try await hybrid.runRequestPurchaseOperation(productId: "premium") { + throw NSError(domain: "CommunityProvider", code: 1) + } + XCTAssertNil(result) + + let providerError = PurchaseError.make( + code: .purchaseError, + productId: "premium", + message: "Provider failure" + ) + let delivery = hybrid.enqueuePurchaseErrorDelivery( + providerError, + expectedEpoch: hybrid.currentConnectionEpoch() + ) + _ = try await delivery.value + _ = try await hybrid.enqueueEndOperation { true }.value + XCTAssertEqual( + probe.events, + [ErrorCode.purchaseError.rawValue, ErrorCode.purchaseError.rawValue] + ) + } + + func testStaleEpochDeliveryDoesNotSuppressFallbackError() async throws { + let hybrid = HybridRnIap() + let probe = EventProbe() + try hybrid.addPurchaseErrorListener { error in probe.record(error.code) } + _ = try await hybrid.enqueueConnectOperation { true }.value + let epoch = hybrid.currentConnectionEpoch() + let holder = DeliveryTaskHolder() + + let result = try await hybrid.runRequestPurchaseOperation { + let delivery = hybrid.enqueuePurchaseErrorDelivery( + PurchaseError.make(code: .networkError, message: "Stale"), + expectedEpoch: epoch + 1 + ) + await holder.set(delivery) + throw NSError(domain: "CommunityProvider", code: 1) + } + XCTAssertNil(result) + let heldTask = await holder.task + let delivery = try XCTUnwrap(heldTask) + _ = try await delivery.value + _ = try await hybrid.enqueueEndOperation { true }.value + XCTAssertEqual(probe.events, [ErrorCode.purchaseError.rawValue]) + } + func testDelayedPurchaseErrorCallbacksAreSuppressedBeforeTeardown() async throws { let hybrid = HybridRnIap() let probe = EventProbe() diff --git a/libraries/react-native-iap/example/screens/OfferCode.tsx b/libraries/react-native-iap/example/screens/OfferCode.tsx index 1ee197633..7ce1286fc 100644 --- a/libraries/react-native-iap/example/screens/OfferCode.tsx +++ b/libraries/react-native-iap/example/screens/OfferCode.tsx @@ -15,7 +15,6 @@ import {openRedeemOfferCode, useIAP} from 'react-native-iap'; const isVegaOS = (): boolean => String(Platform.OS) === 'kepler'; -// Platform-specific content helpers const getPlatformContent = () => { if (isVegaOS()) { return { @@ -32,17 +31,19 @@ const getPlatformContent = () => { const isIOS = Platform.OS === 'ios'; return { - buttonText: isIOS ? '🎁 Redeem Offer Code' : '🎁 Open Play Store', - buttonSubtext: isIOS ? 'Enter code in-app' : 'Redeem in Play Store', + buttonText: isIOS ? '🎁 Redeem Offer Code' : '🎁 Open Redemption Flow', + buttonSubtext: isIOS + ? 'Enter code in-app' + : 'Redeem with the selected store', howItWorks: isIOS ? '• Tap the button below to open the redemption sheet\n• Enter your offer code\n• The system will validate and apply the code\n• Your purchase will appear in purchase history' - : '• Tap the button to open Google Play Store\n• Enter your promo code in the Play Store\n• Complete the redemption process\n• Return to this app to see your purchase', + : '• Open the selected store’s redemption flow\n• Enter a code if the store supports redemption\n• Refresh available purchases after completing redemption', platformNote: isIOS ? 'iOS supports in-app code redemption via StoreKit' - : 'Android requires redemption through Google Play Store', + : 'Android redemption support depends on the selected store', testingInfo: isIOS ? '• Use TestFlight or App Store Connect to generate test codes\n• Test on real devices (not simulators)\n• Sandbox environment supports offer codes' - : '• Generate promo codes in Google Play Console\n• Test with your Google account\n• Ensure app is properly configured for IAP', + : '• Use the selected store’s test codes and test account\n• Follow that provider’s sandbox instructions', }; }; @@ -70,7 +71,6 @@ export default function OfferCodeScreen() { setIsRedeeming(true); try { - // Unified cross-platform redemption flow const purchase = await openRedeemOfferCode(); if (purchase) { Alert.alert( @@ -85,7 +85,7 @@ export default function OfferCodeScreen() { } else { Alert.alert( 'Redemption Requested', - 'Google Play opens its redeem page; stores without one open nothing. Refresh available purchases after redeeming.', + 'The selected store opens its redemption flow when supported. Refresh available purchases after redeeming.', ); } } catch (error) { diff --git a/libraries/react-native-iap/example/vega/bun.lock b/libraries/react-native-iap/example/vega/bun.lock index 81417e796..5952a7aea 100644 --- a/libraries/react-native-iap/example/vega/bun.lock +++ b/libraries/react-native-iap/example/vega/bun.lock @@ -505,7 +505,7 @@ "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], - "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + "compression": ["compression@1.8.2", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -701,7 +701,7 @@ "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], - "joi": ["joi@17.13.7", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ=="], + "joi": ["joi@17.13.8", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], diff --git a/libraries/react-native-iap/example/vega/osv-scanner.toml b/libraries/react-native-iap/example/vega/osv-scanner.toml index 36ebb8a8d..8a4244c67 100644 --- a/libraries/react-native-iap/example/vega/osv-scanner.toml +++ b/libraries/react-native-iap/example/vega/osv-scanner.toml @@ -1,9 +1,19 @@ [[IgnoredVulns]] id = "GHSA-5p2g-fcmc-qvqq" -ignoreUntil = 2026-10-14 -reason = "No patched image-size release exists; Metro uses it only during trusted Vega builds." +ignoreUntil = 2026-10-30 +reason = "The fix is image-size 2.0.3, but Metro pins ^1.0.2 so the lock cannot take it; Metro uses it only during trusted Vega builds." [[IgnoredVulns]] id = "GHSA-w3rx-r6r6-pgpr" -ignoreUntil = 2026-10-14 -reason = "No patched image-size release exists; Metro uses it only during trusted Vega builds." +ignoreUntil = 2026-10-30 +reason = "The fix is image-size 2.0.3, but Metro pins ^1.0.2 so the lock cannot take it; Metro uses it only during trusted Vega builds." + +[[IgnoredVulns]] +id = "GHSA-vfj7-8cjw-p6xm" +ignoreUntil = 2026-10-30 +reason = "No patched braces release exists. It is reached only by build tooling that parses repository-controlled glob patterns during trusted Vega builds: https://github.com/advisories/GHSA-vfj7-8cjw-p6xm." + +[[IgnoredVulns]] +id = "GHSA-hp3w-g68c-fv3c" +ignoreUntil = 2026-10-30 +reason = "No patched sprintf-js release exists; 1.1.3 is both npm latest and still affected. It is reached only via argparse 1 and js-yaml 3 in Metro and React Native CLI config tooling during trusted Vega builds: https://github.com/advisories/GHSA-hp3w-g68c-fv3c." diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index bf8a2d51e..662faa046 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -85,6 +85,8 @@ class HybridRnIap: HybridRnIapSpec { private var pendingDuplicatePurchaseUpdateSuppressions: [String: Int] = [:] private var pendingRequestPurchaseErrorSuppressions: [String: Int] = [:] private var pendingOnDemandInitErrorSuppressions: [String: Int] = [:] + // Shared across requests; concurrent requests may observe each other's count. + private var enqueuedPurchaseErrorDeliveries: UInt64 = 0 private let pendingPurchaseUpdates = PendingEventBuffer( capacity: HybridRnIap.maxPendingEvents, label: "pendingPurchaseUpdates" @@ -175,10 +177,21 @@ class HybridRnIap: HybridRnIapSpec { } if isCurrent, !reuseExistingConnection { RnIapLog.failure("initConnection", error: error) - let err = RnIapHelper.makePurchaseErrorResult( - code: .initConnection, - message: error.localizedDescription - ) + // A provider failure already names its fix; keep its code. + let err: NitroPurchaseResult + if let purchaseError = error as? PurchaseError { + err = RnIapHelper.makePurchaseErrorResult( + code: purchaseError.code, + message: purchaseError.message, + purchaseError.productId, + debugMessage: purchaseError.debugMessage + ) + } else { + err = RnIapHelper.makePurchaseErrorResult( + code: .initConnection, + message: error.localizedDescription + ) + } self.sendPurchaseError(err, productId: nil) } return false @@ -370,7 +383,7 @@ class HybridRnIap: HybridRnIapSpec { "requestPurchase.native", iosPayload ) - let result = try await self.runRequestPurchaseOperation { + let result = try await self.runRequestPurchaseOperation(productId: iosRequest.sku) { try await OpenIapModule.shared.requestPurchase(props) } if result != nil { @@ -505,13 +518,25 @@ class HybridRnIap: HybridRnIapSpec { "finishTransaction", ["transactionId": iosParams.transactionId] ) _ = try await self.runConnectedOperation { - guard let purchaseInput = try await self.purchaseToFinish( - transactionId: iosParams.transactionId, - loadTransactions: { try await OpenIapModule.shared.getAllTransactionsIOS() } - ) else { return } + let purchaseInput: OpenIAP.PurchaseInput + if let json = iosParams.purchaseJson { + let object = try JSONSerialization.jsonObject(with: Data(json.utf8)) + purchaseInput = try OpenIapSerialization.purchaseInput(from: object) + } else { + guard let cached = try await self.purchaseToFinish( + transactionId: iosParams.transactionId, + loadTransactions: { + try await OpenIapModule.shared.getAvailablePurchases(nil).compactMap { + guard case .purchaseIos(let purchase) = $0 else { return nil } + return purchase + } + } + ) else { return } + purchaseInput = cached + } try await OpenIapModule.shared.finishTransaction( purchase: purchaseInput, - isConsumable: nil + isConsumable: iosParams.isConsumable ) } RnIapLog.result("finishTransaction", true) @@ -665,7 +690,8 @@ class HybridRnIap: HybridRnIapSpec { isValid: item.isValid, productId: RnIapHelper.wrapString(item.productId), state: IapkitPurchaseState(fromString: item.state.rawValue) ?? .unknown, - store: IapStore(fromString: item.store.rawValue) ?? .unknown + store: IapStore(fromString: item.store.rawValue) ?? .unknown, + storeId: item.storeId ) } // Convert errors if present @@ -1027,6 +1053,29 @@ class HybridRnIap: HybridRnIapSpec { } } + func restorePurchases() throws -> Promise { + return Promise.async { + do { + RnIapLog.payload("restorePurchases", nil) + let ok = try await self.runConnectedOperation { + try await OpenIapModule.shared.restorePurchases() + return true + } + RnIapLog.result("restorePurchases", ok) + return ok + } catch let purchaseError as PurchaseError { + RnIapLog.failure("restorePurchases", error: purchaseError) + throw OpenIapException.from(purchaseError) + } catch let connectionError as OpenIapException { + RnIapLog.failure("restorePurchases", error: connectionError) + throw connectionError + } catch { + RnIapLog.failure("restorePurchases", error: error) + throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) + } + } + } + func syncIOS() throws -> Promise { return Promise.async { do { @@ -1383,6 +1432,8 @@ class HybridRnIap: HybridRnIapSpec { transactionId: String, loadTransactions: () async throws -> [OpenIAP.PurchaseIOS] ) async throws -> OpenIAP.PurchaseInput? { + // StoreKit transaction ids are numeric; community providers finish + // through the JSON path and never reach this lookup. guard UInt64(transactionId) != nil else { throw OpenIapException.make(code: .purchaseError, message: "Invalid transaction identifier") } @@ -1440,10 +1491,14 @@ class HybridRnIap: HybridRnIapSpec { } func runRequestPurchaseOperation( + productId: String? = nil, _ operation: @escaping () async throws -> OpenIAP.RequestPurchaseResult? ) async throws -> OpenIAP.RequestPurchaseResult? { try await runConnectedOperation { let epoch = self.currentConnectionEpoch() + let enqueuedErrorsBefore = self.listenerLock.withLock { + self.enqueuedPurchaseErrorDeliveries + } do { let result = try await operation() await self.deliverRequestPurchaseResultIfNeeded( @@ -1454,6 +1509,22 @@ class HybridRnIap: HybridRnIapSpec { } catch let purchaseError as PurchaseError { self.deliverRequestPurchaseError(purchaseError) throw purchaseError + } catch { + // A provider that enqueued its own error owns delivery. + RnIapLog.failure("requestPurchase", error: error) + let enqueuedErrorsAfter = self.listenerLock.withLock { + self.enqueuedPurchaseErrorDeliveries + } + if enqueuedErrorsAfter == enqueuedErrorsBefore { + self.deliverRequestPurchaseFallbackError( + PurchaseError.wrap( + error, + fallback: .purchaseError, + productId: productId + ) + ) + } + return nil } } } @@ -1608,12 +1679,6 @@ class HybridRnIap: HybridRnIapSpec { } private func deliverRequestPurchaseError(_ error: PurchaseError) { - let result = RnIapHelper.makePurchaseErrorResult( - code: error.code, - message: error.message, - error.productId, - debugMessage: error.debugMessage - ) let key = RnIapHelper.makeErrorDedupKey( code: error.code.rawValue, productId: error.productId @@ -1621,6 +1686,17 @@ class HybridRnIap: HybridRnIapSpec { listenerLock.withLock { pendingRequestPurchaseErrorSuppressions[key, default: 0] += 1 } + deliverRequestPurchaseFallbackError(error) + } + + // No suppression: a fallback provider never emits the same error later. + private func deliverRequestPurchaseFallbackError(_ error: PurchaseError) { + let result = RnIapHelper.makePurchaseErrorResult( + code: error.code, + message: error.message, + error.productId, + debugMessage: error.debugMessage + ) sendPurchaseError(result, productId: error.productId, dedupe: false) } @@ -1646,6 +1722,11 @@ class HybridRnIap: HybridRnIapSpec { _ error: PurchaseError, expectedEpoch: UInt64 ) -> Task { + listenerLock.withLock { + if connectionEpoch == expectedEpoch { + enqueuedPurchaseErrorDeliveries &+= 1 + } + } return enqueueLifecycleOperation { guard self.isCurrentEpoch(expectedEpoch), !self.consumeOnDemandInitErrorSuppression(error), @@ -1662,7 +1743,7 @@ class HybridRnIap: HybridRnIapSpec { await MainActor.run { guard self.isCurrentEpoch(expectedEpoch) else { return } RnIapLog.result("purchaseErrorListener", payload) - self.sendPurchaseError(nitroError, productId: error.productId) + self.sendPurchaseError(nitroError, productId: error.productId, dedupe: false) } } } diff --git a/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index 6466f03d2..d6ae5b1b9 100644 --- a/libraries/react-native-iap/ios/RnIapHelper.swift +++ b/libraries/react-native-iap/ios/RnIapHelper.swift @@ -423,6 +423,8 @@ enum RnIapHelper { currentPlanId: wrapString(dictionary["currentPlanId"] as? String), ids: wrapStringArray(dictionary["ids"] as? [String]), store: store, + storeId: dictionary["storeId"] as? String ?? (store == .google ? "play" : store.stringValue), + platform: .ios, quantity: doubleValue(dictionary["quantity"]) ?? 0, purchaseState: purchaseState, isAutoRenewing: boolValue(dictionary["isAutoRenewing"]) ?? false, diff --git a/libraries/react-native-iap/osv-scanner.toml b/libraries/react-native-iap/osv-scanner.toml index e297d1ccd..61ca5c97b 100644 --- a/libraries/react-native-iap/osv-scanner.toml +++ b/libraries/react-native-iap/osv-scanner.toml @@ -1,10 +1,24 @@ [[IgnoredVulns]] id = "GHSA-5p2g-fcmc-qvqq" -ignoreUntil = 2026-10-14 -reason = "OSV still records no fixed image-size release for this advisory; Metro uses it only during trusted local builds." +ignoreUntil = 2026-10-30 +reason = "image-size reaches this lock only through Metro 0.84.4, pinned by @expo/metro 56.0.0 and resolved for @react-native/community-cli-plugin 0.86.2. The advisories are fixed in image-size 2.0.3, and Metro 0.84.5 drops it; @expo/metro 56.0.1+ pins 0.84.5 inside the locked ~56.0.0 range, so re-resolving Metro clears both. Until then Metro uses it only during trusted local builds." + +[[IgnoredVulns]] +id = "GHSA-86w9-cpqp-85rv" +ignoreUntil = 2026-10-30 +reason = "No patched node-forge release is available. Expo's development CLI uses it for certificate tooling; it is excluded from the published SDK runtime. Development certificate verification remains affected until the upstream fix ships: https://github.com/advisories/GHSA-86w9-cpqp-85rv." [[IgnoredVulns]] id = "GHSA-w3rx-r6r6-pgpr" -ignoreUntil = 2026-10-14 -reason = "OSV still records no fixed image-size release for this advisory; Metro uses it only during trusted local builds." +ignoreUntil = 2026-10-30 +reason = "image-size reaches this lock only through Metro 0.84.4, pinned by @expo/metro 56.0.0 and resolved for @react-native/community-cli-plugin 0.86.2. The advisories are fixed in image-size 2.0.3, and Metro 0.84.5 drops it; @expo/metro 56.0.1+ pins 0.84.5 inside the locked ~56.0.0 range, so re-resolving Metro clears both. Until then Metro uses it only during trusted local builds." +[[IgnoredVulns]] +id = "GHSA-vfj7-8cjw-p6xm" +ignoreUntil = 2026-10-30 +reason = "No patched braces release exists. It is reached only by build, test, and CLI tooling that parses repository-controlled glob patterns; the published SDK package declares no runtime npm dependencies: https://github.com/advisories/GHSA-vfj7-8cjw-p6xm." + +[[IgnoredVulns]] +id = "GHSA-hp3w-g68c-fv3c" +ignoreUntil = 2026-10-30 +reason = "No patched sprintf-js release exists; 1.1.3 is both npm latest and still affected. It is reached only via argparse 1 and js-yaml 3 in Jest coverage tooling; the published SDK package declares no runtime npm dependencies: https://github.com/advisories/GHSA-hp3w-g68c-fv3c." diff --git a/libraries/react-native-iap/src/__tests__/conformance.test.ts b/libraries/react-native-iap/src/__tests__/conformance.test.ts index 38a360755..e268fd2ab 100644 --- a/libraries/react-native-iap/src/__tests__/conformance.test.ts +++ b/libraries/react-native-iap/src/__tests__/conformance.test.ts @@ -26,6 +26,9 @@ const CATALOG: Record = { 'dev.hyo.martie.lifetime': 'in-app', }; +// A community provider reports Unknown with its own stable id. +const COMMUNITY_STORE_ID = 'community-fixture'; + const store = { owned: new Map(), unfinished: new Set(), @@ -54,6 +57,7 @@ function toPurchase(record: FakeRecord) { isAutoRenewing: record.type === 'subs' && record.state === 'purchased', quantity: 1, store: 'google', + storeId: 'play', platform: 'android', transactionDate: 1_700_000_000_000, transactionId: record.token, @@ -171,6 +175,10 @@ const mockIap: Record = { store.owned.set(record.token, record); store.unfinished.add(record.token); const purchase = toPurchase(record); + if (forced === 'community') { + purchase.store = 'unknown'; + purchase.storeId = COMMUNITY_STORE_ID; + } purchaseUpdatedListeners.forEach((listener) => listener(purchase)); return purchase; }), @@ -207,6 +215,13 @@ const mockIap: Record = { removeUserChoiceBillingListenerAndroid: jest.fn(), getStorefront: jest.fn(async () => 'USA'), + hasActiveSubscriptions: jest.fn(async (subscriptionIds?: string[]) => + [...store.owned.values()].some( + (record) => + record.type === 'subs' && + (!subscriptionIds?.length || subscriptionIds.includes(record.sku)), + ), + ), getActiveSubscriptions: jest.fn(async (subscriptionIds?: string[]) => [...store.owned.values()] .filter((record) => record.type === 'subs') @@ -483,6 +498,16 @@ describe('conformance: react-native-iap', () => { ); expect(purchase.store).toBeTruthy(); expect(purchase.store).not.toBe('unknown'); + expect(purchase.storeId).toBe('play'); + expect(purchase.storeId).toBeTruthy(); + + store.forced.set('dev.hyo.martie.lifetime', 'community'); + const community = await IAP.requestPurchase( + androidRequest('dev.hyo.martie.lifetime'), + ); + expect(community.store).toBe('unknown'); + expect(community.storeId).toBe(COMMUNITY_STORE_ID); + expect(community.storeId).toBeTruthy(); }); it('identifiers.purchase-token-is-stable-across-reads', async () => { diff --git a/libraries/react-native-iap/src/__tests__/first-purchase-notice.test.ts b/libraries/react-native-iap/src/__tests__/first-purchase-notice.test.ts index 8b742e08c..2061a5e4c 100644 --- a/libraries/react-native-iap/src/__tests__/first-purchase-notice.test.ts +++ b/libraries/react-native-iap/src/__tests__/first-purchase-notice.test.ts @@ -26,6 +26,7 @@ const purchase = (purchaseState: PurchaseState): PurchaseIOS => ({ purchaseState, quantity: 1, store: 'apple', + storeId: 'apple', transactionDate: 0, transactionId: '2000000000000001', }); diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts index 01217c136..88a9b0597 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts @@ -1,3 +1,4 @@ +import * as nativeRestore from '../../utils/restore-purchases'; /* eslint-disable import/first */ import React, {act} from 'react'; import {createRoot, type Root} from 'test-renderer'; @@ -48,6 +49,7 @@ import type { FetchProductsResult, ProductAndroid, ProductSubscriptionAndroid, + PurchaseError, } from '../../types'; const inAppProduct = ( @@ -98,7 +100,9 @@ describe('hooks/useIAP (renderer)', () => { let mockHasActiveSubscriptions: jest.SpiedFunction< typeof IAP.hasActiveSubscriptions >; - let mockSyncIOS: jest.SpiedFunction; + let mockRestorePurchases: jest.SpiedFunction< + typeof nativeRestore.restorePurchasesNative + >; beforeEach(() => { capturedPurchaseListener = undefined; @@ -114,7 +118,9 @@ describe('hooks/useIAP (renderer)', () => { .mockResolvedValue(false); jest.spyOn(IAP, 'finishTransaction').mockResolvedValue(undefined); mockFetchProducts = jest.spyOn(IAP, 'fetchProducts').mockResolvedValue([]); - mockSyncIOS = jest.spyOn(IAP, 'syncIOS').mockResolvedValue(true); + mockRestorePurchases = jest + .spyOn(nativeRestore, 'restorePurchasesNative') + .mockResolvedValue(undefined); jest.spyOn(IAP, 'purchaseUpdatedListener').mockImplementation((cb: any) => { capturedPurchaseListener = cb; return {remove: jest.fn()}; @@ -172,6 +178,7 @@ describe('hooks/useIAP (renderer)', () => { transactionDate: Date.now(), platform: 'ios', store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -216,6 +223,7 @@ describe('hooks/useIAP (renderer)', () => { transactionDate: Date.now(), platform: 'ios', store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -782,9 +790,10 @@ describe('hooks/useIAP (renderer)', () => { expect(thrown).toBe(hasSubsError); }); - it('calls onError when restorePurchases fails (syncIOS error on iOS)', async () => { - const restoreError = new Error('Failed to restore'); - mockSyncIOS.mockRejectedValueOnce(restoreError); + it('calls onError when restorePurchases fails (provider error on iOS)', async () => { + mockRestorePurchases.mockRejectedValueOnce( + new Error('Failed to restore'), + ); let api: any; const onError = jest.fn(); @@ -807,14 +816,55 @@ describe('hooks/useIAP (renderer)', () => { } }); - expect(mockSyncIOS).toHaveBeenCalled(); - expect(onError).toHaveBeenCalledWith(restoreError); + expect(mockRestorePurchases).toHaveBeenCalled(); expect(onError).toHaveBeenCalledTimes(1); - expect(thrown).toBe(restoreError); + expect(onError).toHaveBeenCalledWith(thrown); + expect(thrown).toMatchObject({ + code: IAP.ErrorCode.Unknown, + message: 'Failed to restore', + }); + expect(mockGetAvailablePurchases).not.toHaveBeenCalled(); }); - it('rejects when restorePurchases syncIOS returns false on iOS', async () => { - mockSyncIOS.mockResolvedValueOnce(false); + it('keeps the native error code on a cancelled restore', async () => { + mockRestorePurchases.mockRejectedValueOnce( + new Error('{"code":"user-cancelled","message":"User cancelled"}'), + ); + + let api: any; + const onError = jest.fn(); + const Harness = () => { + api = useIAP({onError}); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + let thrown: unknown; + await act(async () => { + try { + await api.restorePurchases(); + } catch (error) { + thrown = error; + } + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(thrown); + expect(thrown).toMatchObject({code: IAP.ErrorCode.UserCancelled}); + expect(IAP.isUserCancelledError(thrown)).toBe(true); + expect(mockGetAvailablePurchases).not.toHaveBeenCalled(); + }); + + it('rejects when restorePurchases rejects an incomplete provider restore', async () => { + mockRestorePurchases.mockRejectedValueOnce( + Object.assign(new Error('Store purchase restore did not complete'), { + code: IAP.ErrorCode.SyncError, + }), + ); let api: any; const onError = jest.fn(); @@ -841,7 +891,7 @@ describe('hooks/useIAP (renderer)', () => { expect(onError).toHaveBeenCalledWith(thrown); expect(thrown).toMatchObject({ code: IAP.ErrorCode.SyncError, - message: 'App Store purchase sync did not complete', + message: 'Store purchase restore did not complete', }); expect(mockGetAvailablePurchases).not.toHaveBeenCalled(); }); @@ -876,7 +926,7 @@ describe('hooks/useIAP (renderer)', () => { expect(thrown).toBe(purchaseError); }); - it('restorePurchases calls syncIOS then getAvailablePurchases on iOS', async () => { + it('restorePurchases calls the provider then refreshes available purchases on iOS', async () => { let api: any; const Harness = () => { api = useIAP(); @@ -892,8 +942,8 @@ describe('hooks/useIAP (renderer)', () => { await api.restorePurchases(); }); - expect(mockSyncIOS).toHaveBeenCalled(); - expect(mockGetAvailablePurchases).toHaveBeenCalled(); + expect(mockRestorePurchases).toHaveBeenCalled(); + expect(mockGetAvailablePurchases).toHaveBeenCalledTimes(1); }); it('does not call onError when operations succeed', async () => { @@ -1013,6 +1063,85 @@ describe('hooks/useIAP (renderer)', () => { }); }); + describe('purchase error events', () => { + const captureErrorListener = (): { + current: ((error: PurchaseError) => void) | undefined; + } => { + const holder: {current: ((error: PurchaseError) => void) | undefined} = { + current: undefined, + }; + jest + .spyOn(IAP, 'purchaseErrorListener') + .mockImplementation((listener) => { + holder.current = listener; + return {remove: jest.fn()}; + }); + return holder; + }; + + it('forwards a developer-error event to onPurchaseError', async () => { + const holder = captureErrorListener(); + const onPurchaseError = jest.fn(); + const Harness = () => { + useIAP({onPurchaseError}); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + const developerError = { + code: IAP.ErrorCode.DeveloperError, + message: 'Info.plist dev.hyo.openiap.PROVIDER must name a linked provider.', + }; + act(() => { + holder.current?.(developerError); + }); + + expect(onPurchaseError).toHaveBeenCalledWith(developerError); + }); + + it('drops init-connection events while disconnected but keeps developer-error', async () => { + const holder = captureErrorListener(); + const onPurchaseError = jest.fn(); + let api: any; + const Harness = () => { + api = useIAP({onPurchaseError}); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false); + await act(async () => { + await api.reconnect(); + }); + expect(api.connected).toBe(false); + + act(() => { + holder.current?.({ + code: IAP.ErrorCode.InitConnection, + message: 'Failed to initialize billing connection', + }); + }); + expect(onPurchaseError).not.toHaveBeenCalled(); + + const developerError = { + code: IAP.ErrorCode.DeveloperError, + message: 'Info.plist dev.hyo.openiap.PROVIDER must name a linked provider.', + }; + act(() => { + holder.current?.(developerError); + }); + expect(onPurchaseError).toHaveBeenCalledWith(developerError); + }); + }); + describe('reconnect', () => { it('reconnects and returns true on success', async () => { let api: any; @@ -1254,6 +1383,7 @@ describe('hooks/useIAP (renderer)', () => { transactionDate: Date.now(), platform: 'ios', store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, diff --git a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts index c332c5767..216ccaf0d 100644 --- a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts @@ -94,6 +94,7 @@ describe('Amazon Vega public API', () => { productId: 'coins', transactionDate: Date.now(), store: 'amazon', + storeId: 'amazon', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -117,6 +118,7 @@ describe('Amazon Vega public API', () => { productId: 'premium', transactionDate: Date.now(), store: 'amazon', + storeId: 'amazon', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -137,6 +139,7 @@ describe('Amazon Vega public API', () => { productId: 'premium', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -230,6 +233,7 @@ describe('Amazon Vega public API', () => { purchaseState: 'purchased', quantity: 1, store: 'amazon', + storeId: 'amazon', transactionDate: 0, }; await expect(IAP.finishTransaction({purchase})).rejects.toThrow( @@ -267,6 +271,7 @@ describe('Amazon Vega public API', () => { productId: 'coins', transactionDate: Date.now(), store: 'amazon', + storeId: 'amazon', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index bfba966b0..ee2912b36 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -4,7 +4,7 @@ import {Platform} from 'react-native'; import {ErrorCode} from '../types'; -import type {DiscountOfferInputIOS} from '../types'; +import type {DiscountOfferInputIOS, Purchase} from '../types'; // Minimal Nitro IAP mock to exercise wrappers const mockIap: any = { @@ -19,6 +19,8 @@ const mockIap: any = { requestPurchase: jest.fn(async () => undefined), getAvailablePurchases: jest.fn(async () => []), finishTransaction: jest.fn(async () => true), + restorePurchases: jest.fn(async () => true), + hasActiveSubscriptions: jest.fn(async () => false), // listeners addPurchaseUpdatedListener: jest.fn(), @@ -105,6 +107,7 @@ describe('Public API (src/index.ts)', () => { }); beforeEach(() => { jest.clearAllMocks(); + mockIap.restorePurchases = jest.fn(async () => true); let purchaseUpdatedToken = 1; mockIap.addPurchaseUpdatedListener.mockImplementation( () => purchaseUpdatedToken++, @@ -160,6 +163,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -172,6 +176,7 @@ describe('Public API (src/index.ts)', () => { expect.objectContaining({ productId: 'p1', store: 'apple', + storeId: 'apple', }), ); @@ -202,6 +207,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -246,6 +252,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -280,6 +287,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -421,6 +429,59 @@ describe('Public API (src/index.ts)', () => { expect(listener).not.toHaveBeenCalled(); }); + it('purchaseUpdatedListener reports an undecodable store identity instead of throwing', () => { + const purchaseListener = jest.fn(); + const errorListener = jest.fn(); + IAP.purchaseUpdatedListener(purchaseListener); + IAP.purchaseErrorListener(errorListener); + const nativeHandler = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + const nitroPurchase = { + id: 't1', + productId: 'p1', + transactionDate: Date.now(), + store: 'unknown', + storeId: '!!!', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + expect(() => nativeHandler(nitroPurchase)).not.toThrow(); + expect(purchaseListener).not.toHaveBeenCalled(); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.BillingResponseJsonParseError, + productId: 'p1', + }), + ); + }); + + it('purchaseUpdatedListener reports a missing iOS transactionId instead of throwing', () => { + const purchaseListener = jest.fn(); + const errorListener = jest.fn(); + IAP.purchaseUpdatedListener(purchaseListener); + IAP.purchaseErrorListener(errorListener); + const nativeHandler = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + const nitroPurchase = { + id: 't1', + productId: 'p1', + transactionDate: Date.now(), + store: 'unknown', + storeId: 'community.shop', + platform: 'ios', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + expect(() => nativeHandler(nitroPurchase)).not.toThrow(); + expect(purchaseListener).not.toHaveBeenCalled(); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.BillingResponseJsonParseError, + productId: 'p1', + }), + ); + }); + it('multiple purchaseUpdatedListeners all receive events from single native handler', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); @@ -436,6 +497,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -467,6 +529,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p2', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -571,6 +634,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -659,6 +723,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -693,6 +758,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -735,6 +801,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -1339,28 +1406,32 @@ describe('Public API (src/index.ts)', () => { ); }); - it('Android path merges inapp+subs results', async () => { + it('Android reads all ownership through the provider contract once', async () => { Object.assign(Platform, {OS: 'android'}); const nitro = (id: string) => ({ id: `t-${id}`, productId: id, transactionDate: Date.now(), store: 'google', + storeId: 'play', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, }); - mockIap.getAvailablePurchases - .mockResolvedValueOnce([nitro('p1')]) - .mockResolvedValueOnce([nitro('s1')]); - const res = await IAP.getAvailablePurchases(); - expect(mockIap.getAvailablePurchases).toHaveBeenNthCalledWith(1, { - android: {type: 'in-app', includeSuspended: false}, + mockIap.getAvailablePurchases.mockResolvedValueOnce([ + nitro('p1'), + nitro('s1'), + ]); + const res = await IAP.getAvailablePurchases({ + includeSuspendedAndroid: true, }); - expect(mockIap.getAvailablePurchases).toHaveBeenNthCalledWith(2, { - android: {type: 'subs', includeSuspended: false}, + expect(mockIap.getAvailablePurchases).toHaveBeenCalledTimes(1); + expect(mockIap.getAvailablePurchases).toHaveBeenCalledWith({ + android: {includeSuspended: true}, }); - expect(res.map((p: any) => p.productId).sort()).toEqual(['p1', 's1']); + expect( + res.map((purchase: Purchase) => purchase.productId).sort(), + ).toEqual(['p1', 's1']); }); it('rejects a mixed valid and malformed native purchase list', async () => { @@ -1370,13 +1441,15 @@ describe('Public API (src/index.ts)', () => { productId: 'valid', transactionDate: Date.now(), store: 'google', + storeId: 'play', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, }; - mockIap.getAvailablePurchases - .mockResolvedValueOnce([valid]) - .mockResolvedValueOnce([{id: 'malformed'}]); + mockIap.getAvailablePurchases.mockResolvedValueOnce([ + valid, + {id: 'malformed'}, + ]); await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ code: 'billing-response-json-parse-error', @@ -1408,6 +1481,7 @@ describe('Public API (src/index.ts)', () => { productId: 'premium', transactionDate: Date.now(), store: 'google', + storeId: 'play', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -1419,6 +1493,95 @@ describe('Public API (src/index.ts)', () => { }); }); + it.each([ + ['ios', 'apple'], + ['android', 'google'], + ['android', 'horizon'], + ['android', 'amazon'], + ])('rejects contradictory %s %s identity', async (platform, store) => { + Object.assign(Platform, {OS: platform}); + mockIap.getAvailablePurchases.mockResolvedValue([ + { + id: 'txn', + transactionId: 'txn', + productId: 'sku', + transactionDate: 1, + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + store, + storeId: 'other', + }, + ]); + await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + + it('preserves community identity in Android ownership reads and restore', async () => { + Object.assign(Platform, {OS: 'android'}); + mockIap.getAvailablePurchases.mockImplementation( + async (options: {android?: {type?: string}}) => { + if (options.android?.type) { + throw new Error( + 'The provider supports canonical ownership reads only', + ); + } + return [ + { + id: 'community', + productId: 'premium', + transactionDate: 1, + store: 'unknown', + storeId: 'community-fixture', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }, + ]; + }, + ); + await expect(IAP.getAvailablePurchases()).resolves.toEqual([ + expect.objectContaining({ + store: 'unknown', + storeId: 'community-fixture', + }), + ]); + await expect(IAP.restorePurchases()).resolves.toBeUndefined(); + }); + + it.each([ + undefined, + '', + 'play', + 'apple', + 'google', + 'horizon', + 'amazon', + 'auto', + 'none', + 'unknown', + 'bad id', + 'store\n', + ])('rejects invalid community ownership identity %s', async (storeId) => { + Object.assign(Platform, {OS: 'android'}); + mockIap.getAvailablePurchases.mockResolvedValue([ + { + id: 'community', + productId: 'premium', + transactionDate: 1, + store: 'unknown', + storeId, + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }, + ]); + await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + it('rejects a foreign store in an Android available-purchase list', async () => { Object.assign(Platform, {OS: 'android'}); mockIap.getAvailablePurchases @@ -1428,6 +1591,7 @@ describe('Public API (src/index.ts)', () => { productId: 'premium', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -1462,6 +1626,7 @@ describe('Public API (src/index.ts)', () => { productId: 'premium_monthly', transactionDate: Date.now(), store: 'amazon', + storeId: 'amazon', quantity: 1, purchaseState: 'purchased', isAutoRenewing: true, @@ -1480,6 +1645,7 @@ describe('Public API (src/index.ts)', () => { expect.objectContaining({ productId: 'premium_monthly', store: 'amazon', + storeId: 'amazon', }), ]); }); @@ -1493,6 +1659,25 @@ describe('Public API (src/index.ts)', () => { }); describe('finishTransaction', () => { + it('forwards full community purchase identity to Android completion', async () => { + Object.assign(Platform, {OS: 'android'}); + const purchase = { + id: 'opaque-id', + productId: 'sku', + store: 'unknown', + storeId: 'community-fixture', + purchaseToken: 'opaque-receipt', + }; + mockIap.finishTransaction.mockResolvedValueOnce(true); + await IAP.finishTransaction({purchase, isConsumable: true}); + expect(mockIap.finishTransaction).toHaveBeenCalledWith({ + android: { + purchaseToken: 'opaque-receipt', + purchaseJson: JSON.stringify(purchase), + isConsumable: true, + }, + }); + }); it('iOS requires purchase.id and returns success state', async () => { Object.assign(Platform, {OS: 'ios'}); await expect(IAP.finishTransaction({purchase: {id: ''}})).rejects.toThrow( @@ -1505,6 +1690,49 @@ describe('Public API (src/index.ts)', () => { ).resolves.toBeUndefined(); }); + it('iOS finishes a minimal purchase by transaction lookup only', async () => { + Object.assign(Platform, {OS: 'ios'}); + mockIap.finishTransaction.mockResolvedValueOnce(true); + await IAP.finishTransaction({purchase: {id: 'tid'}}); + const params = mockIap.finishTransaction.mock.calls[0][0]; + expect(params).toStrictEqual({ + ios: {transactionId: 'tid', isConsumable: undefined}, + }); + expect(params.ios).not.toHaveProperty('purchaseJson'); + }); + + it('iOS sends purchaseJson for a purchase carrying an apple identity', async () => { + Object.assign(Platform, {OS: 'ios'}); + const purchase = {id: 'tid', store: 'apple', storeId: 'apple'}; + mockIap.finishTransaction.mockResolvedValueOnce(true); + await IAP.finishTransaction({purchase, isConsumable: false}); + expect(mockIap.finishTransaction).toHaveBeenCalledWith({ + ios: { + transactionId: 'tid', + purchaseJson: JSON.stringify(purchase), + isConsumable: false, + }, + }); + }); + + it('iOS sends purchaseJson for a community Apple provider purchase', async () => { + Object.assign(Platform, {OS: 'ios'}); + const purchase = { + id: 'opaque-id', + store: 'unknown', + storeId: 'community-fixture', + }; + mockIap.finishTransaction.mockResolvedValueOnce(true); + await IAP.finishTransaction({purchase}); + expect(mockIap.finishTransaction).toHaveBeenCalledWith({ + ios: { + transactionId: 'opaque-id', + purchaseJson: JSON.stringify(purchase), + isConsumable: undefined, + }, + }); + }); + it('Android requires token; maps consume flag', async () => { Object.assign(Platform, {OS: 'android'}); await expect( @@ -1536,6 +1764,22 @@ describe('Public API (src/index.ts)', () => { ).resolves.toBeUndefined(); }); + it('iOS: preserves community completion errors', async () => { + Object.assign(Platform, {OS: 'ios'}); + mockIap.finishTransaction.mockRejectedValueOnce( + new Error('Transaction not found'), + ); + await expect( + IAP.finishTransaction({ + purchase: { + id: 'opaque-id', + store: 'unknown', + storeId: 'community-fixture', + }, + }), + ).rejects.toThrow('Transaction not found'); + }); + it('iOS: propagates native finish failures', async () => { Object.assign(Platform, {OS: 'ios'}); const error = new Error( @@ -1672,6 +1916,7 @@ describe('Public API (src/index.ts)', () => { productId: 'premium', transactionDate: 1700000000000, store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: true, @@ -1680,6 +1925,7 @@ describe('Public API (src/index.ts)', () => { id: 'redeemed-transaction', productId: 'premium', store: 'apple', + storeId: 'apple', }); }); @@ -1696,6 +1942,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -1713,6 +1960,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p2', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -1738,6 +1986,7 @@ describe('Public API (src/index.ts)', () => { productId: 'premium', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -1822,6 +2071,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p3', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -1899,20 +2149,20 @@ describe('Public API (src/index.ts)', () => { expect(console.error).not.toHaveBeenCalled(); }); - it('restorePurchases on iOS calls syncIOS first', async () => { + it('restorePurchases calls the native provider first', async () => { Object.assign(Platform, {OS: 'ios'}); - mockIap.syncIOS = jest.fn(async () => true); + mockIap.restorePurchases = jest.fn(async () => true); await IAP.restorePurchases(); - expect(mockIap.syncIOS).toHaveBeenCalled(); + expect(mockIap.restorePurchases).toHaveBeenCalled(); }); - it('restorePurchases on iOS rejects when syncIOS returns false', async () => { + it('restorePurchases rejects when native restore returns false', async () => { Object.assign(Platform, {OS: 'ios'}); - mockIap.syncIOS = jest.fn(async () => false); + mockIap.restorePurchases = jest.fn(async () => false); await expect(IAP.restorePurchases()).rejects.toMatchObject({ code: ErrorCode.SyncError, - message: 'App Store purchase sync did not complete', + message: 'Store purchase restore did not complete', }); expect(mockIap.getAvailablePurchases).not.toHaveBeenCalled(); }); @@ -2250,6 +2500,35 @@ describe('Public API (src/index.ts)', () => { }); }); + it.each(['android', 'ios', 'ios-fallback'])( + 'deepLinkToSubscriptions preserves native errors on %s', + async (path) => { + Object.assign(Platform, {OS: path === 'android' ? 'android' : 'ios'}); + const nativeError = new Error( + JSON.stringify({ + code: 'feature-not-supported', + message: 'Provider has no subscription management UI', + debugMessage: 'Community provider rejection', + }), + ); + const reject = jest.fn().mockRejectedValueOnce(nativeError); + if (path === 'android') { + mockIap.deepLinkToSubscriptionsAndroid = reject; + } else if (path === 'ios') { + mockIap.deepLinkToSubscriptionsIOS = reject; + } else { + delete mockIap.deepLinkToSubscriptionsIOS; + mockIap.showManageSubscriptionsIOS = reject; + } + await expect(IAP.deepLinkToSubscriptions()).rejects.toMatchObject({ + code: 'feature-not-supported', + message: 'Provider has no subscription management UI', + debugMessage: 'Community provider rejection', + }); + expect(reject).toHaveBeenCalledTimes(1); + }, + ); + it('deepLinkToSubscriptions uses iOS deeplink when available', async () => { Object.assign(Platform, {OS: 'ios'}); mockIap.deepLinkToSubscriptionsIOS = jest.fn(async () => true); @@ -2290,6 +2569,7 @@ describe('Public API (src/index.ts)', () => { productId: 'premium', transactionDate: 1700000000000, store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: true, @@ -2298,6 +2578,7 @@ describe('Public API (src/index.ts)', () => { id: 'redeemed-transaction', productId: 'premium', store: 'apple', + storeId: 'apple', }); expect(mockIap.presentCodeRedemptionSheetIOS).toHaveBeenCalledTimes(1); }); @@ -2521,9 +2802,7 @@ describe('Public API (src/index.ts)', () => { describe('hasActiveSubscriptions', () => { it('should return true when there are active subscriptions', async () => { Object.assign(Platform, {OS: 'ios'}); - mockIap.getActiveSubscriptions.mockResolvedValueOnce([ - {productId: 'sub1', isActive: true}, - ]); + mockIap.hasActiveSubscriptions.mockResolvedValueOnce(true); const result = await IAP.hasActiveSubscriptions(); @@ -2532,7 +2811,7 @@ describe('Public API (src/index.ts)', () => { it('should return false when there are no active subscriptions', async () => { Object.assign(Platform, {OS: 'ios'}); - mockIap.getActiveSubscriptions.mockResolvedValueOnce([]); + mockIap.hasActiveSubscriptions.mockResolvedValueOnce(false); const result = await IAP.hasActiveSubscriptions(); @@ -2544,7 +2823,7 @@ describe('Public API (src/index.ts)', () => { async (platform) => { Object.assign(Platform, {OS: platform}); const error = new Error('Failed to fetch'); - mockIap.getActiveSubscriptions.mockRejectedValueOnce(error); + mockIap.hasActiveSubscriptions.mockRejectedValueOnce(error); await expect(IAP.hasActiveSubscriptions()).rejects.toThrow( 'Failed to fetch', @@ -2552,9 +2831,83 @@ describe('Public API (src/index.ts)', () => { }, ); }); + it('preserves false provider status for a nonempty inactive list', async () => { + mockIap.getActiveSubscriptions.mockResolvedValueOnce([ + {productId: 'expired', isActive: false}, + ]); + mockIap.hasActiveSubscriptions.mockResolvedValueOnce(false); + expect(await IAP.hasActiveSubscriptions()).toBe(false); + expect(mockIap.hasActiveSubscriptions).toHaveBeenCalled(); + expect(mockIap.getActiveSubscriptions).not.toHaveBeenCalled(); + }); + }); + + it('preserves unsupported native receipt verification across both APIs', async () => { + Object.assign(Platform, {OS: 'android'}); + const nativeError = new Error( + JSON.stringify({ + code: 'feature-not-supported', + message: 'Use the vendor backend', + }), + ); + mockIap.verifyPurchase.mockRejectedValueOnce(nativeError); + await expect( + IAP.verifyPurchase({ + google: { + sku: 'sku', + accessToken: 'fixture', + packageName: 'test.app', + purchaseToken: 'opaque', + }, + }), + ).rejects.toMatchObject({code: 'feature-not-supported'}); + mockIap.verifyPurchaseWithProvider = jest + .fn() + .mockRejectedValueOnce(nativeError); + await expect( + IAP.verifyPurchaseWithProvider({provider: 'iapkit'}), + ).rejects.toMatchObject({code: 'feature-not-supported'}); }); describe('verifyPurchaseWithProvider', () => { + it.each([ + ['google', undefined, 'play'], + ['apple', undefined, 'apple'], + ['horizon', undefined, 'horizon'], + ['amazon', undefined, 'amazon'], + ['unknown', 'community-store', 'community-store'], + ])( + 'preserves verification identity for %s', + async (store, storeId, expected) => { + mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce({ + provider: 'iapkit', + iapkit: {isValid: true, state: 'entitled', store, storeId}, + }); + const result = await IAP.verifyPurchaseWithProvider({ + provider: 'iapkit', + }); + expect(result.iapkit?.storeId).toBe(expected); + }, + ); + + it.each([ + ['unknown', undefined], + ['unknown', 'unknown'], + ['google', 'community-store'], + ['apple', 'play'], + ])( + 'rejects contradictory verification identity for %s/%s', + async (store, storeId) => { + mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce({ + provider: 'iapkit', + iapkit: {isValid: true, state: 'entitled', store, storeId}, + }); + await expect( + IAP.verifyPurchaseWithProvider({provider: 'iapkit'}), + ).rejects.toThrow(/store identity/); + }, + ); + beforeEach(() => { mockIap.verifyPurchaseWithProvider = jest.fn(); }); @@ -2574,6 +2927,7 @@ describe('Public API (src/index.ts)', () => { productId: 'premium.monthly', state: 'entitled', store: 'apple', + storeId: 'apple', }, }; mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce(mockResult); @@ -2619,6 +2973,7 @@ describe('Public API (src/index.ts)', () => { isValid: true, state: 'entitled', store: 'google', + storeId: 'play', }, }; mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce(mockResult); @@ -2647,6 +3002,7 @@ describe('Public API (src/index.ts)', () => { isValid: true, state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }, }; mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce(mockResult); @@ -2707,6 +3063,7 @@ describe('Public API (src/index.ts)', () => { isValid: false, state: 'expired', store: 'apple', + storeId: 'apple', }, }; mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce(mockResult); @@ -2744,7 +3101,7 @@ describe('Public API (src/index.ts)', () => { Object.assign(Platform, {OS: 'ios'}); const mockResult = { provider: 'iapkit', - iapkit: [], + iapkit: null, }; mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce(mockResult); @@ -2842,6 +3199,7 @@ describe('Public API (src/index.ts)', () => { isValid: true, state: 'pending-acknowledgment', store: 'google', + storeId: 'play', }, }; mockIap.verifyPurchaseWithProvider.mockResolvedValueOnce(mockResult); @@ -3552,6 +3910,33 @@ describe('Public API (src/index.ts)', () => { 'native failure', ); }); + + it('reports an undecodable purchase instead of throwing', () => { + const billingIssueListener = jest.fn(); + const errorListener = jest.fn(); + IAP.subscriptionBillingIssueListener(billingIssueListener); + IAP.purchaseErrorListener(errorListener); + const nativeHandler = + mockIap.addSubscriptionBillingIssueListener.mock.calls[0][0]; + const nitroPurchase = { + id: 't1', + productId: 'p1', + transactionDate: Date.now(), + store: 'unknown', + storeId: '!!!', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + expect(() => nativeHandler(nitroPurchase)).not.toThrow(); + expect(billingIssueListener).not.toHaveBeenCalled(); + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.BillingResponseJsonParseError, + productId: 'p1', + }), + ); + }); }); describe('native failure normalization', () => { @@ -3704,6 +4089,7 @@ describe('Public API (src/index.ts)', () => { productId: 'p1', transactionDate: Date.now(), store: 'google', + storeId: 'play', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -3783,6 +4169,7 @@ describe('Public API (src/index.ts)', () => { productId: 'premium', transactionDate: Date.now(), store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, diff --git a/libraries/react-native-iap/src/__tests__/standardized-offer-types.test.ts b/libraries/react-native-iap/src/__tests__/standardized-offer-types.test.ts index c13b5bad3..c60cc27b1 100644 --- a/libraries/react-native-iap/src/__tests__/standardized-offer-types.test.ts +++ b/libraries/react-native-iap/src/__tests__/standardized-offer-types.test.ts @@ -547,6 +547,7 @@ describe('Standardized Offer Types', () => { isAutoRenewing: true, quantity: 1, store: 'google', + storeId: 'play', pendingPurchaseUpdateAndroid: { products: ['premium_yearly'], purchaseToken: 'pending_upgrade_token', @@ -575,6 +576,7 @@ describe('Standardized Offer Types', () => { isAutoRenewing: false, quantity: 1, store: 'google', + storeId: 'play', // No pending update - this is a regular purchase }; diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index 72af6eaed..532cac3ce 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -35,6 +35,7 @@ const purchase = (overrides: Partial = {}): NitroPurchase => ({ transactionDate: 123, purchaseToken: 'receipt', store: 'apple', + storeId: 'apple', quantity: 1, purchaseState: 'purchased', isAutoRenewing: false, @@ -302,6 +303,7 @@ describe('type-bridge utilities', () => { expect(result).toEqual( expect.objectContaining({ store: 'apple', + storeId: 'apple', transactionId: 'canonical-transaction-id', currentPlanId: 'premium-monthly', ids: ['com.example.product', 'addon'], @@ -354,6 +356,7 @@ describe('type-bridge utilities', () => { purchaseToken: null, purchaseTokenAndroid: 'purchase-token', store: 'google', + storeId: 'play', purchaseState: 'unknown', purchaseStateAndroid: 1, isAutoRenewing: true, @@ -363,6 +366,7 @@ describe('type-bridge utilities', () => { expect(result).toEqual( expect.objectContaining({ store: 'google', + storeId: 'play', purchaseState: 'purchased', autoRenewingAndroid: true, transactionId: 'GPA.1234', @@ -379,12 +383,38 @@ describe('type-bridge utilities', () => { purchaseToken: null, purchaseTokenAndroid: 'purchase-token', store: 'google', + storeId: 'play', }), ) as PurchaseAndroid; expect(result.transactionId).toBeNull(); }); + it.each([ + [null, false, null], + [null, true, null], + [undefined, false, false], + [undefined, true, true], + [false, true, false], + [true, false, true], + ] as const)( + 'preserves renewal %s with legacy flag %s as %s', + (autoRenewingAndroid, isAutoRenewing, expected) => { + const result = convertNitroPurchaseToPurchase( + purchase({ + store: 'unknown', + storeId: 'samsung', + autoRenewingAndroid, + isAutoRenewing, + }), + ) as PurchaseAndroid; + + expect(result.storeId).toBe('samsung'); + expect(result.autoRenewingAndroid).toBe(expected); + expect(result.isAutoRenewing).toBe(isAutoRenewing); + }, + ); + it.each(['amazon', 'horizon'] as const)( 'preserves a %s receipt id as transactionId', (store) => { @@ -394,6 +424,7 @@ describe('type-bridge utilities', () => { transactionId: null, purchaseToken: `${store}-receipt`, store, + storeId: store, }), ) as PurchaseAndroid; @@ -406,6 +437,7 @@ describe('type-bridge utilities', () => { const result = convertNitroPurchaseToPurchase( purchase({ store: 'google', + storeId: 'play', currentPlanId: 'premium-yearly', ids: ['premium-monthly', 'premium-yearly'], pendingPurchaseUpdateAndroid: { @@ -425,6 +457,7 @@ describe('type-bridge utilities', () => { const purchased = convertNitroPurchaseToPurchase( purchase({ store: 'google', + storeId: 'play', purchaseState: 1 as never, transactionId: null, isAutoRenewing: false, @@ -433,10 +466,14 @@ describe('type-bridge utilities', () => { }), ) as PurchaseAndroid; const pending = convertNitroPurchaseToPurchase( - purchase({store: 'google', purchaseState: 2 as never}), + purchase({store: 'google', storeId: 'play', purchaseState: 2 as never}), ); const unknown = convertNitroPurchaseToPurchase( - purchase({store: 'other' as never, purchaseState: 99 as never}), + purchase({ + store: 'other' as never, + storeId: 'future-store', + purchaseState: 99 as never, + }), ); expect(purchased.purchaseState).toBe('purchased'); @@ -572,3 +609,34 @@ describe('type-bridge utilities', () => { expect(checkTypeSynchronization()).toEqual({isSync: true, issues: []}); }); }); + +test('preserves a community provider storeId through the Nitro purchase bridge', () => { + const nativePurchase = purchase({ + store: 'unknown', + storeId: 'community-fixture', + }); + const result = convertNitroPurchaseToPurchase(nativePurchase); + expect(result.store).toBe('unknown'); + expect(result.storeId).toBe('community-fixture'); +}); + +test('preserves a community iOS purchase shape and opaque transaction identifier', () => { + const result = convertNitroPurchaseToPurchase( + purchase({ + store: 'unknown', + storeId: 'community-fixture', + platform: 'ios', + transactionId: 'opaque-txn', + quantityIOS: 2, + environmentIOS: 'Sandbox', + }), + ); + expect(result).toMatchObject({ + store: 'unknown', + storeId: 'community-fixture', + transactionId: 'opaque-txn', + quantityIOS: 2, + environmentIOS: 'Sandbox', + }); + expect(result).not.toHaveProperty('isAcknowledgedAndroid'); +}); 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 3006d98e3..9d6380b07 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -484,6 +484,7 @@ describe('Amazon Vega adapter', () => { purchaseToken: 'receipt-1', currentPlanId: null, store: 'amazon', + storeId: 'amazon', transactionId: 'receipt-1', }), ]); @@ -597,6 +598,7 @@ describe('Amazon Vega adapter', () => { productId: 'coins_100', purchaseToken: 'recovered-receipt', store: 'amazon', + storeId: 'amazon', }), ]); expect(listener).toHaveBeenCalledWith( @@ -645,6 +647,7 @@ describe('Amazon Vega adapter', () => { productId: 'coins_100', purchaseToken: 'recovered-receipt', store: 'amazon', + storeId: 'amazon', }), ]); expect(listener).toHaveBeenCalledWith( @@ -1154,7 +1157,7 @@ describe('Amazon Vega adapter', () => { ); const listener = jest.fn(); module.addPurchaseUpdatedListener(listener); - await expect(module.restorePurchases()).resolves.toBeUndefined(); + await expect(module.restorePurchases()).resolves.toBe(true); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ productId: 'premium_monthly', @@ -1444,7 +1447,7 @@ describe('Amazon Vega adapter', () => { purchaseState: 'purchased', }), ]); - await expect(module.restorePurchases()).resolves.toBeUndefined(); + await expect(module.restorePurchases()).resolves.toBe(true); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ id: 'deferred-sub', @@ -1495,6 +1498,7 @@ describe('Amazon Vega adapter', () => { isValid: true, state: 'READY_TO_CONSUME', store: 'amazon', + storeId: 'amazon', }), ) as unknown as jest.MockedFunction; globalThis.fetch = fetchMock; @@ -1521,6 +1525,7 @@ describe('Amazon Vega adapter', () => { isValid: true, state: 'ready-to-consume', store: 'amazon', + storeId: 'amazon', }, }); @@ -1539,6 +1544,7 @@ describe('Amazon Vega adapter', () => { ); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ store: 'amazon', + storeId: 'amazon', userId: 'amazon-user', receiptId: 'receipt-vega-1', expectedProductId: 'amazon.premium.monthly', @@ -1557,6 +1563,7 @@ describe('Amazon Vega adapter', () => { isValid: true, state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', productId: 'premium.monthly', clientPayload: { format: 'toml', @@ -1586,9 +1593,11 @@ describe('Amazon Vega adapter', () => { productId: 'premium.monthly', state: 'entitled', store: 'amazon', + storeId: 'amazon', }); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ store: 'amazon', + storeId: 'amazon', userId: 'amazon-user', receiptId: 'receipt-vega-1', }); @@ -1620,6 +1629,7 @@ describe('Amazon Vega adapter', () => { isValid: true, state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', }), ) as unknown as jest.MockedFunction; globalThis.fetch = fetchMock; @@ -1924,6 +1934,7 @@ describe('Amazon Vega adapter', () => { { state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', }, {status: 200}, ), @@ -1967,6 +1978,7 @@ describe('Amazon Vega adapter', () => { isValid: true, state: 'ENTITLED', store: 'amazon', + storeId: 'amazon', }), ) as unknown as jest.MockedFunction; globalThis.fetch = fetchMock; @@ -2002,6 +2014,7 @@ describe('Amazon Vega adapter', () => { isValid: true, state: 'ENTITLED', store: 'apple', + storeId: 'apple', }, {status: 200}, ), diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 3bfb3914a..989c520aa 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -2,7 +2,10 @@ import {useCallback, useEffect, useState, useRef} from 'react'; import {Platform} from 'react-native'; import {RnIapConsole} from '../utils/debug'; -import {createPurchaseError} from '../utils/errorMapping'; +import { + restorePurchasesNative, + toRestorePurchaseError, +} from '../utils/restore-purchases'; // Internal modules import { @@ -18,7 +21,6 @@ import { verifyPurchaseWithProvider as verifyPurchaseWithProviderTopLevel, getActiveSubscriptions, hasActiveSubscriptions, - syncIOS, getPromotedProductIOS, getBillingChoiceInfoAndroid, isBillingProgramAvailableAndroid, @@ -536,9 +538,7 @@ export function useIAP(options?: UseIapOptions): UseIap { const finishTransaction = useCallback( async (args: MutationFinishTransactionArgs): Promise => { - // Errors propagate: the root API owns validation and error handling, - // including treating iOS "Transaction not found" as already finished. - // Callers handle failures in onPurchaseSuccess. + // The root API preserves provider completion errors. await finishTransactionInternal(args); }, [], @@ -553,23 +553,14 @@ export function useIAP(options?: UseIapOptions): UseIap { const restorePurchases = useCallback( async (options?: PurchaseOptions): Promise => { - if (Platform.OS === 'ios') { - try { - const synced = await syncIOS(); - if (!synced) { - throw createPurchaseError({ - code: ErrorCode.SyncError, - message: 'App Store purchase sync did not complete', - platform: 'ios', - }); - } - } catch (error) { - RnIapConsole.warn('Failed to restore purchases:', error); - invokeOnError(error); - throw error; - } + try { + await restorePurchasesNative(); + } catch (error) { + const purchaseError = toRestorePurchaseError(error); + RnIapConsole.warn('Failed to restore purchases:', error); + invokeOnError(purchaseError); + throw purchaseError; } - // The query helper reports and rethrows its own error, avoiding a second // onError call while keeping restore failure observable to the caller. await getAvailablePurchasesInternal(options); diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index d58dde71d..846f8fa0b 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -1,7 +1,15 @@ // External dependencies import {Platform} from 'react-native'; -// Importing NitroModules installs its dispatcher before IAP is used (no-op in tests). -import {NitroModules} from 'react-native-nitro-modules'; +import { + getNativeIapInstance, + hasNativeIapInstance, + isNativeIapReady, + toErrorMessage, +} from './utils/native-instance'; +import { + restorePurchasesNative, + toRestorePurchaseError, +} from './utils/restore-purchases'; // Internal modules import type { @@ -14,7 +22,7 @@ import type { NitroSubscriptionStatus, RnIap, } from './specs/RnIap.nitro'; -import {ErrorCode} from './types'; +import {ErrorCode, resolveStoreId} from './types'; import type { AppTransaction, AndroidSubscriptionOfferInput, @@ -111,18 +119,6 @@ type NitroPromotedProductListener = Parameters< RnIap['addPromotedProductListenerIOS'] >[0]; -const toErrorMessage = (error: unknown): string => { - if ( - typeof error === 'object' && - error !== null && - 'message' in error && - (error as {message?: unknown}).message != null - ) { - return String((error as {message?: unknown}).message); - } - return String(error ?? ''); -}; - const parseErrorAndLogIfNeeded = ( message: string, error: unknown, @@ -162,7 +158,6 @@ export type { } from './kit-api'; // Create the RnIap HybridObject instance lazily to avoid early JSI crashes -let iapRef: RnIap | null = null; let attachingPendingNativeListeners = false; /** @@ -170,19 +165,7 @@ let attachingPendingNativeListeners = false; * This is useful for platforms like tvOS where Nitro may initialize later. * @returns true if Nitro is ready, false otherwise */ -export const isNitroReady = (): boolean => { - if (iapRef) return true; - if (isVegaOS()) { - iapRef = getVegaAdapter(); - return Boolean(iapRef); - } - try { - iapRef = NitroModules.createHybridObject('RnIap'); - return true; - } catch { - return false; - } -}; +export const isNitroReady = isNativeIapReady; /** * Check if we're running on tvOS. @@ -214,43 +197,9 @@ const isAndroidStoreRuntime = (): boolean => { return Platform.OS === 'android' || isVegaOS(); }; -function getRawIapInstance(): RnIap { - if (iapRef) return iapRef; - - if (isVegaOS()) { - const vegaModule = getVegaAdapter(); - if (!vegaModule) { - throw new Error( - 'Amazon Vega IAP module is unavailable. Install @amazon-devices/keplerscript-appstore-iap-lib in the Vega app target and build with the React Native for Vega kepler platform.', - ); - } - iapRef = vegaModule; - return iapRef; - } - - // Attempt to create the HybridObject and map common Nitro/JSI readiness errors - try { - iapRef = NitroModules.createHybridObject('RnIap'); - } catch (e) { - const msg = toErrorMessage(e); - if ( - msg.includes('Nitro') || - msg.includes('JSI') || - msg.includes('dispatcher') || - msg.includes('HybridObject') - ) { - throw new Error( - 'Nitro runtime not installed yet. Ensure react-native-nitro-modules is initialized before calling IAP.', - ); - } - throw e; - } - return iapRef; -} - const IAP = { get instance(): RnIap { - const instance = getRawIapInstance(); + const instance = getNativeIapInstance(); if (!attachingPendingNativeListeners) { attachingPendingNativeListeners = true; try { @@ -301,20 +250,26 @@ const emitPurchaseUpdateToListeners = ( nitroPurchase: Parameters[0], listeners: Set<(purchase: Purchase) => void>, ) => { - if (validateNitroPurchase(nitroPurchase)) { - const convertedPurchase = convertNitroPurchaseToPurchase(nitroPurchase); - for (const listener of listeners) { - try { - listener(convertedPurchase); - } catch (e) { - RnIapConsole.error('[purchaseUpdatedListener] callback threw:', e); - } - } - } else { + if (!validateNitroPurchase(nitroPurchase)) { RnIapConsole.error( 'Invalid purchase data received from native — productId:', nitroPurchase?.productId ?? 'unknown', ); + return; + } + let convertedPurchase: Purchase; + try { + convertedPurchase = convertNitroPurchaseToPurchase(nitroPurchase); + } catch (error) { + emitPurchaseDecodeError(nitroPurchase, error); + return; + } + for (const listener of listeners) { + try { + listener(convertedPurchase); + } catch (e) { + RnIapConsole.error('[purchaseUpdatedListener] callback threw:', e); + } } }; const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => { @@ -344,7 +299,7 @@ function tryAttachPurchaseUpdateNative( | undefined = receiveDuplicateTransactionUpdatesIOS ? {dedupeTransactionIOS: false} : undefined; - const token = getRawIapInstance().addPurchaseUpdatedListener( + const token = getNativeIapInstance().addPurchaseUpdatedListener( receiveDuplicateTransactionUpdatesIOS ? purchaseUpdateDuplicateNativeHandler : purchaseUpdateNativeHandler, @@ -391,10 +346,33 @@ const purchaseErrorNativeHandler: NitroPurchaseErrorListener = (error) => { } }; +// A malformed event payload reports the same code a failed read throws. +const emitPurchaseDecodeError = ( + nitroPurchase: Parameters[0], + cause: unknown, +): void => { + const error: PurchaseError = { + code: ErrorCode.BillingResponseJsonParseError, + message: 'Malformed purchase payload received from the native bridge', + productId: nitroPurchase.productId, + }; + RnIapConsole.error( + '[purchaseUpdatedListener] failed to decode purchase:', + cause, + ); + for (const listener of purchaseErrorJsListeners) { + try { + listener(error); + } catch (e) { + RnIapConsole.error('[purchaseErrorListener] callback threw:', e); + } + } +}; + function tryAttachPurchaseErrorNative(): void { if (purchaseErrorNativeAttached) return; attachNativeListenerOrDefer('purchaseErrorListener', () => { - getRawIapInstance().addPurchaseErrorListener(purchaseErrorNativeHandler); + getNativeIapInstance().addPurchaseErrorListener(purchaseErrorNativeHandler); purchaseErrorNativeAttached = true; }); } @@ -424,7 +402,7 @@ const promotedProductNativeHandler: NitroPromotedProductListener = ( function tryAttachPromotedProductNative(): void { if (promotedProductNativeAttached) return; attachNativeListenerOrDefer('promotedProductListenerIOS', () => { - getRawIapInstance().addPromotedProductListenerIOS( + getNativeIapInstance().addPromotedProductListenerIOS( promotedProductNativeHandler, ); promotedProductNativeAttached = true; @@ -500,7 +478,7 @@ export const purchaseUpdatedListener = ( } try { - getRawIapInstance().removePurchaseUpdatedListener(token); + getNativeIapInstance().removePurchaseUpdatedListener(token); if (receiveDuplicateTransactionUpdatesIOS) { purchaseUpdateDuplicateNativeToken = null; purchaseUpdateDuplicateNativeAttached = false; @@ -543,7 +521,7 @@ export const purchaseErrorListener = ( if (purchaseErrorNativeAttached) { try { - getRawIapInstance().removePurchaseErrorListener( + getNativeIapInstance().removePurchaseErrorListener( purchaseErrorNativeHandler, ); purchaseErrorNativeAttached = false; @@ -614,7 +592,7 @@ const userChoiceBillingNativeHandler: NitroUserChoiceBillingListener = ( function tryAttachUserChoiceBillingNative(): void { if (userChoiceBillingNativeAttached) return; attachNativeListenerOrDefer('userChoiceBillingListenerAndroid', () => { - getRawIapInstance().addUserChoiceBillingListenerAndroid( + getNativeIapInstance().addUserChoiceBillingListenerAndroid( userChoiceBillingNativeHandler, ); userChoiceBillingNativeAttached = true; @@ -677,7 +655,7 @@ export const userChoiceBillingListenerAndroid = ( return; } try { - getRawIapInstance().removeUserChoiceBillingListenerAndroid( + getNativeIapInstance().removeUserChoiceBillingListenerAndroid( userChoiceBillingNativeHandler, ); userChoiceBillingNativeAttached = false; @@ -716,7 +694,7 @@ const developerProvidedBillingNativeHandler: NitroDeveloperProvidedBillingListen function tryAttachDeveloperProvidedBillingNative(): void { if (developerProvidedBillingNativeAttached) return; attachNativeListenerOrDefer('developerProvidedBillingListenerAndroid', () => { - getRawIapInstance().addDeveloperProvidedBillingListenerAndroid( + getNativeIapInstance().addDeveloperProvidedBillingListenerAndroid( developerProvidedBillingNativeHandler, ); developerProvidedBillingNativeAttached = true; @@ -790,7 +768,13 @@ const subscriptionBillingIssueNativeHandler: NitroSubscriptionBillingIssueListen ); return; } - const purchase = convertNitroPurchaseToPurchase(nitroPurchase); + let purchase: Purchase; + try { + purchase = convertNitroPurchaseToPurchase(nitroPurchase); + } catch (error) { + emitPurchaseDecodeError(nitroPurchase, error); + return; + } for (const listener of subscriptionBillingIssueJsListeners) { try { listener(purchase); @@ -806,7 +790,7 @@ const subscriptionBillingIssueNativeHandler: NitroSubscriptionBillingIssueListen function tryAttachSubscriptionBillingIssueNative(): void { if (subscriptionBillingIssueNativeAttached) return; attachNativeListenerOrDefer('subscriptionBillingIssueListener', () => { - getRawIapInstance().addSubscriptionBillingIssueListener( + getNativeIapInstance().addSubscriptionBillingIssueListener( subscriptionBillingIssueNativeHandler, ); subscriptionBillingIssueNativeAttached = true; @@ -1049,23 +1033,10 @@ export const getAvailablePurchases: QueryField< options?.includeSuspendedAndroid ?? false, ); - if (isVegaOS()) { - const nitroPurchases = await IAP.instance.getAvailablePurchases({ - android: {includeSuspended}, - }); - return convertAndroidPurchasesOrThrow(nitroPurchases); - } - - // For Android Play/Horizon/Fire OS, query in-app items and subscriptions separately. - const inappNitroPurchases = await IAP.instance.getAvailablePurchases({ - android: {type: 'in-app', includeSuspended}, - }); - const subsNitroPurchases = await IAP.instance.getAvailablePurchases({ - android: {type: 'subs', includeSuspended}, + const nitroPurchases = await IAP.instance.getAvailablePurchases({ + android: {includeSuspended}, }); - - const allNitroPurchases = [...inappNitroPurchases, ...subsNitroPurchases]; - return convertAndroidPurchasesOrThrow(allNitroPurchases); + return convertAndroidPurchasesOrThrow(nitroPurchases); } else { throw unsupportedPlatformError(); } @@ -1263,7 +1234,9 @@ export const currentEntitlementIOS: QueryField< const nitroPurchase = await IAP.instance.currentEntitlementIOS(sku); if (nitroPurchase) { const converted = convertNitroPurchaseToPurchase(nitroPurchase); - return converted.store === 'apple' ? (converted as PurchaseIOS) : null; + return converted.store === 'apple' || converted.store === 'unknown' + ? (converted as PurchaseIOS) + : null; } return null; } catch (error) { @@ -1299,7 +1272,9 @@ export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( const nitroPurchase = await IAP.instance.latestTransactionIOS(sku); if (nitroPurchase) { const converted = convertNitroPurchaseToPurchase(nitroPurchase); - return converted.store === 'apple' ? (converted as PurchaseIOS) : null; + return converted.store === 'apple' || converted.store === 'unknown' + ? (converted as PurchaseIOS) + : null; } return null; } catch (error) { @@ -1627,7 +1602,9 @@ export const initConnection: MutationField<'initConnection'> = async ( */ export const endConnection: MutationField<'endConnection'> = async () => { try { - const result = iapRef ? await IAP.instance.endConnection() : true; + const result = hasNativeIapInstance() + ? await IAP.instance.endConnection() + : true; resetListenerState(); return result; } catch (error) { @@ -1651,32 +1628,15 @@ export const endConnection: MutationField<'endConnection'> = async () => { */ export const restorePurchases: MutationField<'restorePurchases'> = async () => { try { - if (Platform.OS === 'ios') { - const synced = await syncIOS(); - if (!synced) { - throw createPurchaseError({ - code: ErrorCode.SyncError, - message: 'App Store purchase sync did not complete', - platform: 'ios', - }); - } - } + await restorePurchasesNative(); await getAvailablePurchases({ alsoPublishToEventListenerIOS: false, onlyIncludeActiveItemsIOS: true, }); } catch (error) { - const parsedError = parseErrorAndLogIfNeeded( - 'Failed to restore purchases:', - error, - ); - throw createPurchaseError({ - code: parsedError.code, - message: parsedError.message, - responseCode: parsedError.responseCode, - debugMessage: parsedError.debugMessage, - }); + parseErrorAndLogIfNeeded('Failed to restore purchases:', error); + throw toRestorePurchaseError(error); } }; @@ -1965,21 +1925,29 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ( if (!purchase.id) { throw new Error('purchase.id required to finish iOS transaction'); } + // A stored or minimal purchase carries no store identity; native + // finishes those by transaction lookup instead of decoding JSON. params = { ios: { transactionId: purchase.id, + ...(purchase.store != null + ? {purchaseJson: JSON.stringify(purchase)} + : {}), + isConsumable: isConsumable ?? undefined, }, }; } else if (isAndroidStoreRuntime()) { const token = purchase.purchaseToken ?? undefined; - if (!token) { + const community = purchase.store === 'unknown'; + if (!token && !community) { throw new Error('purchaseToken required to finish Android transaction'); } params = { android: { - purchaseToken: token, + purchaseToken: token ?? '', + ...(community ? {purchaseJson: JSON.stringify(purchase)} : {}), isConsumable: isConsumable ?? false, }, }; @@ -1996,15 +1964,18 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ( return; } catch (error) { const parsedError = parseErrorStringToJsonObj(error); - // If iOS transaction has already been auto-finished natively, treat as success - if (Platform.OS === 'ios') { + // StoreKit can finish a transaction before a replay reaches JavaScript. + if ( + Platform.OS === 'ios' && + (purchase.store === 'apple' || purchase.store == null) && + (purchase.storeId === 'apple' || purchase.storeId == null) + ) { const msg = (parsedError.message || '').toString(); const code = (parsedError.code || '').toString(); if ( msg.includes('Transaction not found') || code === 'E_ITEM_UNAVAILABLE' ) { - // Consider already finished return; } } @@ -2351,6 +2322,7 @@ export const verifyPurchaseWithProvider: MutationField< : {productId: result.iapkit.productId}), state: result.iapkit.state, store: result.iapkit.store, + storeId: resolveStoreId(result.iapkit.storeId, result.iapkit.store), } : null, errors: result.errors ?? null, @@ -2539,23 +2511,35 @@ export const deepLinkToSubscriptions: MutationField< > = async (options) => { const resolvedOptions = options ?? undefined; - if (Platform.OS === 'android') { - await IAP.instance.deepLinkToSubscriptionsAndroid?.({ - skuAndroid: resolvedOptions?.skuAndroid ?? undefined, - packageNameAndroid: resolvedOptions?.packageNameAndroid ?? undefined, - }); - return; + if (Platform.OS !== 'android' && Platform.OS !== 'ios') { + throw unsupportedPlatformError(); } - if (Platform.OS === 'ios') { + + try { + if (Platform.OS === 'android') { + await IAP.instance.deepLinkToSubscriptionsAndroid?.({ + skuAndroid: resolvedOptions?.skuAndroid ?? undefined, + packageNameAndroid: resolvedOptions?.packageNameAndroid ?? undefined, + }); + return; + } if (typeof IAP.instance.deepLinkToSubscriptionsIOS === 'function') { await IAP.instance.deepLinkToSubscriptionsIOS(); } else { await IAP.instance.showManageSubscriptionsIOS(); } - return; + } catch (error) { + const parsedError = parseErrorAndLogIfNeeded( + '[deepLinkToSubscriptions] Failed:', + error, + ); + throw createPurchaseError({ + code: parsedError.code, + message: parsedError.message, + responseCode: parsedError.responseCode, + debugMessage: parsedError.debugMessage, + }); } - - throw unsupportedPlatformError(); }; export const deepLinkToSubscriptionsIOS = async (): Promise => { @@ -2681,8 +2665,17 @@ export const getActiveSubscriptions: QueryField< export const hasActiveSubscriptions: QueryField< 'hasActiveSubscriptions' > = async (subscriptionIds) => { - const activeSubscriptions = await getActiveSubscriptions(subscriptionIds); - return activeSubscriptions.length > 0; + try { + return await IAP.instance.hasActiveSubscriptions( + subscriptionIds ?? undefined, + ); + } catch (error) { + const parsedError = parseErrorAndLogIfNeeded( + 'Failed to check active subscriptions:', + error, + ); + throw createPurchaseError(parsedError); + } }; // Type conversion utilities diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index cdedf0f37..266490de3 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -101,16 +101,22 @@ export type BillingChoiceImageLayoutAndroid = | 'rectangular-two-by-two'; export type BillingChoiceScreenTypeAndroid = - 'unspecified' | 'developer-rendered' | 'google-rendered'; + | 'unspecified' + | 'developer-rendered' + | 'google-rendered'; export type DeveloperBillingTypeAndroid = - 'developer-billing-type-unspecified' | 'in-app' | 'external-link'; + | 'developer-billing-type-unspecified' + | 'in-app' + | 'external-link'; export type InAppMessageCategoryAndroid = - 'unknown-in-app-message-category-id' | 'transactional'; + | 'unknown-in-app-message-category-id' + | 'transactional'; export type InAppMessageResponseCodeAndroid = - 'no-action-needed' | 'subscription-status-updated'; + | 'no-action-needed' + | 'subscription-status-updated'; // Android 8.3.0+ export type DeveloperBillingLaunchModeAndroid = @@ -126,7 +132,9 @@ export type ExternalLinkLaunchModeAndroid = // Android 8.2.0+ export type ExternalLinkTypeAndroid = - 'unspecified' | 'link-to-digital-content-offer' | 'link-to-app-download'; + | 'unspecified' + | 'link-to-digital-content-offer' + | 'link-to-app-download'; // ╔══════════════════════════════════════════════════════════════════════════╗ // ║ PARAMS ║ @@ -273,6 +281,8 @@ export interface NitroAvailablePurchasesOptions { */ export interface NitroFinishTransactionIosParams { transactionId: string; + purchaseJson?: string; + isConsumable?: boolean; } /** @@ -280,6 +290,7 @@ export interface NitroFinishTransactionIosParams { */ export interface NitroFinishTransactionAndroidParams { purchaseToken: string; + purchaseJson?: string; isConsumable?: MutationFinishTransactionArgs['isConsumable']; } @@ -471,6 +482,7 @@ export interface NitroVerifyPurchaseWithIapkitResult { productId?: string | null; state: IapkitPurchaseState; store: IapStore; + storeId: string; } export interface NitroIapkitProductClientPayload { @@ -525,6 +537,8 @@ export interface NitroPurchase { ids?: PurchaseCommon['ids']; /** Store where purchase was made */ store: IapStore; + storeId: string; + platform?: IapPlatform; quantity: PurchaseCommon['quantity']; purchaseState: PurchaseCommon['purchaseState']; isAutoRenewing: PurchaseCommon['isAutoRenewing']; @@ -897,6 +911,7 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { * @returns Promise - Success flag * @platform iOS */ + restorePurchases(): Promise; syncIOS(): Promise; /** diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 54449c7f0..45c72105b 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -3,7 +3,25 @@ // Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ +export const StoreIds = { + Apple: 'apple', + Play: 'play', + Horizon: 'horizon', + Amazon: 'amazon', +} as const; + +export function resolveStoreId(value: unknown, store: IapStore): string { + const officialIds: Partial> = {'apple': 'apple', 'google': 'play', 'horizon': 'horizon', 'amazon': 'amazon'}; + const official = officialIds[store]; + const id = value ?? official; + if (typeof id !== 'string' || (official != null ? id !== official : store !== 'unknown' || id.match(/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/)?.[0] !== id || ['auto', 'none', 'unknown', 'apple', 'play', 'google', 'horizon', 'amazon'].includes(id))) { + throw new Error('Invalid store identity'); + } + return id; +} + export interface ActiveSubscription { + /** Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. */ autoRenewingAndroid?: (boolean | null); basePlanIdAndroid?: (string | null); /** @@ -586,6 +604,7 @@ export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product export type IapPlatform = 'ios' | 'android'; +/** Frozen legacy store discriminator. Use storeId for extensible store identity. */ export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon' | 'amazon'; /** Serialization format of a public IAPKit product client payload. */ @@ -856,6 +875,7 @@ export interface Mutation { presentExternalPurchaseNoticeSheetIOS: Promise; /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ requestPurchase?: Promise<(Purchase | Purchase[] | null)>; @@ -1203,6 +1223,7 @@ export interface PromotionalOfferJwsInputIOS { export type Purchase = PurchaseAndroid | PurchaseIOS; export interface PurchaseAndroid extends PurchaseCommon { + /** Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. */ autoRenewingAndroid?: (boolean | null); currentPlanId?: (string | null); dataAndroid?: (string | null); @@ -1210,6 +1231,7 @@ export interface PurchaseAndroid extends PurchaseCommon { id: string; ids?: (string[] | null); isAcknowledgedAndroid?: (boolean | null); + /** Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. */ isAutoRenewing: boolean; /** * Whether the subscription is suspended (Android) @@ -1236,6 +1258,8 @@ export interface PurchaseAndroid extends PurchaseCommon { signatureAndroid?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId?: (string | null); @@ -1262,6 +1286,7 @@ export interface PurchaseCommon { currentPlanId?: (string | null); id: string; ids?: (string[] | null); + /** Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. */ isAutoRenewing: boolean; productId: string; purchaseState: PurchaseState; @@ -1270,6 +1295,8 @@ export interface PurchaseCommon { quantity: number; /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; } @@ -1318,6 +1345,7 @@ export interface PurchaseIOS extends PurchaseCommon { expirationDateIOS?: (number | null); id: string; ids?: (string[] | null); + /** Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. */ isAutoRenewing: boolean; isUpgradedIOS?: (boolean | null); offerIOS?: (PurchaseOfferIOS | null); @@ -1348,6 +1376,8 @@ export interface PurchaseIOS extends PurchaseCommon { revocationTypeIOS?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; storefrontCountryCodeIOS?: (string | null); subscriptionGroupIdIOS?: (string | null); /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -1692,10 +1722,8 @@ export type RequestPurchaseProps = * Platform-specific purchase request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ export interface RequestPurchasePropsByPlatforms { /** Apple-specific purchase parameters */ @@ -1792,10 +1820,8 @@ export interface RequestSubscriptionIosProps { * Platform-specific subscription request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ export interface RequestSubscriptionPropsByPlatforms { /** Apple-specific subscription parameters */ @@ -1905,6 +1931,21 @@ export interface RequestVerifyPurchaseWithIapkitResult { /** The current state of the purchase. */ state: IapkitPurchaseState; store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; +} + +/** + * Store-provider contract shared by the Apple and Android native bindings. + * coreVersion names the native contract build; clientProtocolVersion names the + * Client Protocol build. Capabilities use the conformance provider profile ids. + */ +export interface StoreProviderDescriptor { + capabilities: string[]; + clientProtocolVersion: string; + coreVersion: string; + platform: IapPlatform; + storeId: string; } /** diff --git a/libraries/react-native-iap/src/utils/available-purchases.ts b/libraries/react-native-iap/src/utils/available-purchases.ts index eee77e4c2..ae91c46b8 100644 --- a/libraries/react-native-iap/src/utils/available-purchases.ts +++ b/libraries/react-native-iap/src/utils/available-purchases.ts @@ -1,5 +1,5 @@ import {ErrorCode} from '../types'; -import type {Purchase, PurchaseIOS} from '../types'; +import type {IapPlatform, Purchase, PurchaseIOS} from '../types'; import {createPurchaseError} from './errorMapping'; import { convertNitroPurchaseToPurchase, @@ -11,6 +11,7 @@ const ANDROID_STORES = new Set(['google', 'amazon', 'horizon']); /** Decode an authoritative native purchase list atomically. */ export const convertAvailablePurchasesOrThrow = ( purchases: unknown, + platform?: IapPlatform, ): Purchase[] => { if (!Array.isArray(purchases)) { throw createPurchaseError({ @@ -28,7 +29,13 @@ export const convertAvailablePurchasesOrThrow = ( } try { - return convertNitroPurchaseToPurchase(purchase); + if (platform && purchase.platform && purchase.platform !== platform) { + throw new Error('Purchase platform differs from its native bridge'); + } + return convertNitroPurchaseToPurchase( + purchase, + platform ?? purchase.platform, + ); } catch { throw createPurchaseError({ code: ErrorCode.BillingResponseJsonParseError, @@ -38,18 +45,22 @@ export const convertAvailablePurchasesOrThrow = ( }); }; -/** Decode an authoritative StoreKit purchase list without filtering entries. */ +/** Decode an authoritative Apple-platform purchase list without filtering entries. */ export const convertApplePurchasesOrThrow = ( purchases: unknown, ): PurchaseIOS[] => { - const decoded = convertAvailablePurchasesOrThrow(purchases); + const decoded = convertAvailablePurchasesOrThrow(purchases, 'ios'); const invalidIndex = decoded.findIndex( - (purchase) => purchase.store !== 'apple', + (purchase) => + (purchase.store !== 'apple' && purchase.store !== 'unknown') || + !('transactionId' in purchase) || + typeof purchase.transactionId !== 'string' || + !purchase.transactionId, ); if (invalidIndex !== -1) { throw createPurchaseError({ code: ErrorCode.BillingResponseJsonParseError, - message: `Native StoreKit bridge returned a non-Apple purchase at index ${invalidIndex}`, + message: `Native Apple bridge returned an invalid iOS purchase at index ${invalidIndex}`, }); } return decoded as PurchaseIOS[]; @@ -61,7 +72,8 @@ export const convertAndroidPurchasesOrThrow = ( ): Purchase[] => { const decoded = convertAvailablePurchasesOrThrow(purchases); const invalidIndex = decoded.findIndex( - (purchase) => !ANDROID_STORES.has(purchase.store), + (purchase) => + !ANDROID_STORES.has(purchase.store) && purchase.store !== 'unknown', ); if (invalidIndex !== -1) { throw createPurchaseError({ diff --git a/libraries/react-native-iap/src/utils/native-instance.ts b/libraries/react-native-iap/src/utils/native-instance.ts new file mode 100644 index 000000000..76d43fcc1 --- /dev/null +++ b/libraries/react-native-iap/src/utils/native-instance.ts @@ -0,0 +1,67 @@ +import {NitroModules} from 'react-native-nitro-modules'; +import type {RnIap} from '../specs/RnIap.nitro'; +import {getVegaIapModule as getVegaAdapter, isVegaOS} from '../vega'; + +let iapRef: RnIap | null = null; + +export const toErrorMessage = (error: unknown): string => { + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + (error as {message?: unknown}).message != null + ) { + return String((error as {message?: unknown}).message); + } + return String(error ?? ''); +}; + +export const isNativeIapReady = (): boolean => { + if (iapRef) return true; + if (isVegaOS()) { + iapRef = getVegaAdapter(); + return Boolean(iapRef); + } + try { + iapRef = NitroModules.createHybridObject('RnIap'); + return true; + } catch { + return false; + } +}; + +export function getNativeIapInstance(): RnIap { + if (iapRef) return iapRef; + + if (isVegaOS()) { + const vegaModule = getVegaAdapter(); + if (!vegaModule) { + throw new Error( + 'Amazon Vega IAP module is unavailable. Install @amazon-devices/keplerscript-appstore-iap-lib in the Vega app target and build with the React Native for Vega kepler platform.', + ); + } + iapRef = vegaModule; + return iapRef; + } + + // Attempt to create the HybridObject and map common Nitro/JSI readiness errors + try { + iapRef = NitroModules.createHybridObject('RnIap'); + } catch (e) { + const msg = toErrorMessage(e); + if ( + msg.includes('Nitro') || + msg.includes('JSI') || + msg.includes('dispatcher') || + msg.includes('HybridObject') + ) { + throw new Error( + 'Nitro runtime not installed yet. Ensure react-native-nitro-modules is initialized before calling IAP.', + ); + } + throw e; + } + return iapRef; +} + +export const hasNativeIapInstance = (): boolean => iapRef !== null; diff --git a/libraries/react-native-iap/src/utils/restore-purchases.ts b/libraries/react-native-iap/src/utils/restore-purchases.ts new file mode 100644 index 000000000..fb2a3a76d --- /dev/null +++ b/libraries/react-native-iap/src/utils/restore-purchases.ts @@ -0,0 +1,34 @@ +import {Platform} from 'react-native'; +import {ErrorCode} from '../types'; +import {isVegaOS} from '../vega'; +import {getNativeIapInstance} from './native-instance'; +import {parseErrorStringToJsonObj} from './error'; +import { + createPurchaseError, + type PurchaseError as PurchaseErrorInstance, +} from './errorMapping'; + +// Shared by the root API and useIAP so a native JSON error keeps its code. +export const toRestorePurchaseError = ( + error: unknown, +): PurchaseErrorInstance => { + const parsedError = parseErrorStringToJsonObj(error); + return createPurchaseError({ + code: parsedError.code, + message: parsedError.message, + responseCode: parsedError.responseCode, + debugMessage: parsedError.debugMessage, + }); +}; + +export const restorePurchasesNative = async (): Promise => { + if (isVegaOS()) return; + const restored = await getNativeIapInstance().restorePurchases(); + if (restored !== true) { + throw createPurchaseError({ + code: ErrorCode.SyncError, + message: 'Store purchase restore did not complete', + platform: Platform.OS === 'ios' ? 'ios' : 'android', + }); + } +}; diff --git a/libraries/react-native-iap/src/utils/type-bridge.ts b/libraries/react-native-iap/src/utils/type-bridge.ts index 0af58e745..05d8b6b17 100644 --- a/libraries/react-native-iap/src/utils/type-bridge.ts +++ b/libraries/react-native-iap/src/utils/type-bridge.ts @@ -28,6 +28,7 @@ import type { RenewalInfoIOS, SubscriptionStatusIOS, } from '../types'; +import {resolveStoreId} from '../types'; import {RnIapConsole} from './debug'; const PLATFORM_IOS: IapPlatform = 'ios'; @@ -393,6 +394,7 @@ export function convertProductToProductSubscription( */ export function convertNitroPurchaseToPurchase( nitroPurchase: NitroPurchase, + platform = nitroPurchase.platform, ): Purchase { let purchaseState = normalizePurchaseState( nitroPurchase.purchaseState ?? nitroPurchase.purchaseStateAndroid, @@ -408,7 +410,10 @@ export function convertNitroPurchaseToPurchase( const store = normalizeStore(nitroPurchase.store); - if (store === STORE_APPLE) { + if ( + store === STORE_APPLE || + (store === STORE_UNKNOWN && platform === 'ios') + ) { const transactionId = toNullableString(nitroPurchase.transactionId); if (transactionId == null) { throw new Error('Apple purchase is missing transactionId'); @@ -420,6 +425,7 @@ export function convertNitroPurchaseToPurchase( transactionDate: nitroPurchase.transactionDate ?? Date.now(), purchaseToken: nitroPurchase.purchaseToken ?? null, store, + storeId: resolveStoreId(nitroPurchase.storeId, store), quantity: nitroPurchase.quantity ?? 1, purchaseState, isAutoRenewing: Boolean(nitroPurchase.isAutoRenewing), @@ -524,6 +530,7 @@ export function convertNitroPurchaseToPurchase( purchaseToken: nitroPurchase.purchaseToken ?? nitroPurchase.purchaseTokenAndroid ?? null, store, + storeId: resolveStoreId(nitroPurchase.storeId, store), quantity: nitroPurchase.quantity ?? 1, purchaseState, isAutoRenewing: Boolean(nitroPurchase.isAutoRenewing), @@ -533,7 +540,9 @@ export function convertNitroPurchaseToPurchase( // not synthesize a transactionId from it. transactionId: androidTransactionId, autoRenewingAndroid: toNullableBoolean( - nitroPurchase.autoRenewingAndroid ?? nitroPurchase.isAutoRenewing, + nitroPurchase.autoRenewingAndroid === undefined + ? nitroPurchase.isAutoRenewing + : nitroPurchase.autoRenewingAndroid, ), dataAndroid: toNullableString(nitroPurchase.dataAndroid), signatureAndroid: toNullableString(nitroPurchase.signatureAndroid), diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index a4b815345..432dd87f2 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -761,6 +761,7 @@ function mapReceipt( type === 'subs' ? (nonBlankString(receipt.termSku) ?? productId) : null, ids: productId ? [productId] : [], store: 'amazon', + storeId: 'amazon', quantity: 1, purchaseState: isActive ? 'purchased' : 'unknown', isAutoRenewing: type === 'subs' && isActive, @@ -826,7 +827,7 @@ type VegaRnIapModule = Partial & { consumePurchaseAndroid(purchaseToken: string): Promise; /** @deprecated Use openRedeemOfferCode. Scheduled for removal in client protocol 1.0.0. */ openRedeemOfferCodeAndroid(): Promise; - restorePurchases(): Promise; + restorePurchases(): Promise; }; interface RecoveredNitroPurchases { @@ -1326,6 +1327,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { ...(productId == null ? {} : {productId}), state: normalizeIapkitState(json.state), store: 'amazon', + storeId: 'amazon', }; } @@ -1388,6 +1390,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { }, body: JSON.stringify({ store: 'amazon', + storeId: 'amazon', userId, receiptId, ...(amazon.expectedProductId == null @@ -1615,9 +1618,10 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { await finishReceipt(purchaseToken); return true; }, - async restorePurchases(): Promise { + async restorePurchases(): Promise { const purchases = await getAvailablePurchases(); purchases.forEach(emitPurchaseUpdated); + return true; }, addPurchaseUpdatedListener(listener): number { const token = nextPurchaseUpdateListenerToken++; diff --git a/libraries/react-native-iap/yarn.lock b/libraries/react-native-iap/yarn.lock index f281ebead..0a6326221 100644 --- a/libraries/react-native-iap/yarn.lock +++ b/libraries/react-native-iap/yarn.lock @@ -6061,17 +6061,18 @@ __metadata: linkType: hard "compression@npm:^1.7.1, compression@npm:^1.7.4": - version: 1.8.1 - resolution: "compression@npm:1.8.1" + version: 1.8.2 + resolution: "compression@npm:1.8.2" dependencies: bytes: 3.1.2 compressible: ~2.0.18 debug: 2.6.9 + destroy: 1.2.0 negotiator: ~0.6.4 on-headers: ~1.1.0 safe-buffer: 5.2.1 vary: ~1.1.2 - checksum: 906325935180cd3507d30ed898fb129deccab03689383d55536245a94610f5003923bb14c95ee6adc8d658ee13be549407eb4346ef55169045f3e41e9969808e + checksum: 1120f4b2f725216f0fa4077033bddc34a22acdf969015c52b29823d13a6637288cc802873e2fb79e27458984a11db2fdf43f81955b104d6a6f728401a3f1d074 languageName: node linkType: hard @@ -8160,9 +8161,9 @@ __metadata: linkType: hard "http-cache-semantics@npm:^4.1.1": - version: 4.2.0 - resolution: "http-cache-semantics@npm:4.2.0" - checksum: 7a7246ddfce629f96832791176fd643589d954e6f3b49548dadb4290451961237fab8fcea41cd2008fe819d95b41c1e8b97f47d088afc0a1c81705287b4ddbcc + version: 4.3.0 + resolution: "http-cache-semantics@npm:4.3.0" + checksum: ca72a39d6a1a45b1cb1ddb37c9bac9ce69737a20e2f37cbf9866198424c370686c0bb19f125813e15da256a2f9d526a0fdfc7236b1f078826f2742ab95c93040 languageName: node linkType: hard @@ -9315,15 +9316,15 @@ __metadata: linkType: hard "joi@npm:^17.2.1": - version: 17.13.7 - resolution: "joi@npm:17.13.7" + version: 17.13.8 + resolution: "joi@npm:17.13.8" dependencies: "@hapi/hoek": ^9.3.0 "@hapi/topo": ^5.1.0 "@sideway/address": ^4.1.5 "@sideway/formula": ^3.0.1 "@sideway/pinpoint": ^2.0.0 - checksum: 2f9af4cd84c60b9c493184f8eb127454f2577faae8f868c9acb57ac6f0ca80b75f2d5baaa26c14e6a1bdca391738acec8a3e2dd3430dfb500f2f82c042a90071 + checksum: 8ba537317e0a972b053edb0f656c4bb4207e3869d585b9d7fe92c8f4ba07812780d67dd727346611d2d3aa9b499c7d7af8cfd4d8c08c3453e03d6890f2fa61ac languageName: node linkType: hard @@ -12154,9 +12155,9 @@ __metadata: linkType: hard "source-map-js@npm:^1.2.1": - version: 1.2.1 - resolution: "source-map-js@npm:1.2.1" - checksum: 4eb0cd997cdf228bc253bcaff9340afeb706176e64868ecd20efbe6efea931465f43955612346d6b7318789e5265bdc419bc7669c1cebe3db0eb255f57efa76b + version: 1.2.2 + resolution: "source-map-js@npm:1.2.2" + checksum: d4f3adc1921e145fe84574b5852d7f5bf11e93d6c008a250ffe66a3e698331f066008cc3ba63e720dde07fe74871f1610d2e74532d33b27126e82e57c00df3bf languageName: node linkType: hard diff --git a/openiap-versions.json b/openiap-versions.json index 4c0626527..0c28351f3 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,5 +1,5 @@ { - "clientProtocol": "0.1.1", + "clientProtocol": "0.2.0", "google": "3.6.2", "apple": "3.6.1" } diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 000000000..d63257486 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,9 @@ +[[IgnoredVulns]] +id = "GHSA-vfj7-8cjw-p6xm" +ignoreUntil = 2026-10-30 +reason = "No patched braces release exists. It is reached only by @graphql-codegen/cli (via micromatch / fast-glob, in specs/client), which parses repository-controlled glob patterns; the published SDK packages declare no runtime npm dependencies and the Kit runtime image is a compiled binary: https://github.com/advisories/GHSA-vfj7-8cjw-p6xm." + +[[IgnoredVulns]] +id = "GHSA-7mx3-vvmw-hjmv" +ignoreUntil = 2026-10-30 +reason = "Patched in @graphql-tools/utils 12.0.1, but the graphql-codegen chain still pins ^11 and no in-range fix exists. Reached only by local type generation over repository schema files: https://github.com/advisories/GHSA-7mx3-vvmw-hjmv." diff --git a/package.json b/package.json index 6373a340b..eded16843 100644 --- a/package.json +++ b/package.json @@ -47,18 +47,21 @@ "licenses:inventory": "node scripts/generate-third-party-notices.mjs godot --inventory", "audit:horizon-app-id": "node --test scripts/audit-horizon-example-app-id.test.mjs scripts/verify-horizon-merged-manifest.test.mjs && node scripts/audit-horizon-example-app-id.mjs", "audit:orphan-tags": "node --test scripts/audit-orphan-tags.test.mjs && node scripts/audit-orphan-tags.mjs", - "audit:release-notes": "node --test scripts/audit-release-notes.test.mjs && node scripts/audit-release-notes.mjs origin/main" + "audit:release-notes": "node --test scripts/audit-release-notes.test.mjs && node scripts/audit-release-notes.mjs origin/main", + "stores:generate": "node scripts/generate-store-registry.mjs", + "audit:stores": "node --test scripts/store-registry.test.mjs && node scripts/generate-store-registry.mjs --check" }, "devDependencies": { "@playwright/test": "^1.59.1", "husky": "^9.1.7", + "prettier": "^3.6.2", "typescript": "^5.9.2", "yaml": "^2.9.0" }, "overrides": { "csstype": "3.2.3", "fast-uri": "$fast-uri", - "hono": "4.13.5", + "hono": "4.13.7", "ip-address": "10.7.2", "qs": "$qs", "undici": "7.30.0" diff --git a/packages/apple/CONVENTION.md b/packages/apple/CONVENTION.md index 427f69d34..06f072294 100644 --- a/packages/apple/CONVENTION.md +++ b/packages/apple/CONVENTION.md @@ -36,3 +36,7 @@ cd packages/apple swift build swift test --filter OpenIapTests ``` + +## Store Providers + +Apple reaches the selected store only through the `OpenIapModule` facade over `OpenIapProvider` Info.plist discovery; the contract, identity rules, and parity checklist live in [02-architecture.md](../../knowledge/internal/02-architecture.md#store-providers) and [04-platform-packages.md](../../knowledge/internal/04-platform-packages.md#provider-identity-and-selection). diff --git a/packages/apple/README.md b/packages/apple/README.md index 254319270..7cb25d7fa 100644 --- a/packages/apple/README.md +++ b/packages/apple/README.md @@ -24,6 +24,19 @@ Visit [**openiap.dev**](https://openiap.dev) for complete documentation, API reference, guides, and examples. +## Community store providers + +Link an independent provider and set the app's `dev.hyo.openiap.PROVIDER` +Info.plist key to its Objective-C factory class name. `OpenIapModule` selects +that provider; without the key, it uses Apple App Store. Invalid explicit +configuration fails at connection. + +Providers implement `OpenIapModuleProtocol` and `OpenIapProviderFactory` against +the public `OpenIAP` product. Apple and Android factories expose the same +Client Protocol descriptor and preserve concrete purchase identity. +Follow the [provider guide](https://openiap.dev/docs/guides/store-providers) +for factory versions, Release linking and the public Swift conformance suite. + ## Features - StoreKit 2 support (iOS 15+) @@ -38,7 +51,7 @@ Visit [**openiap.dev**](https://openiap.dev) for complete documentation, API ref | -------- | --------------- | | iOS | 15.0+ | | macOS | 14.0+ | -| tvOS | 15.0+ | +| tvOS | 16.0+ | | watchOS | 8.0+ | | Swift | 5.9+ | diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift index d8748e4ca..78166ecc1 100644 --- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift +++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift @@ -210,6 +210,7 @@ enum StoreKitTypesBridge { transaction.storefrontCountryCode } }(), + storeId: "apple", subscriptionGroupIdIOS: transaction.subscriptionGroupID, transactionDate: transaction.purchaseDate.milliseconds, transactionId: transactionId, diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index c9ad6cb10..1d88f1edc 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -5,6 +5,29 @@ import Foundation +public enum StoreIds { + public static let Apple = "apple" + public static let Play = "play" + public static let Horizon = "horizon" + public static let Amazon = "amazon" +} + +private func resolveStoreId(_ store: IapStore, _ value: String?) -> String? { + let official: String? + switch store { + case .apple: official = "apple" + case .google: official = "play" + case .horizon: official = "horizon" + case .amazon: official = "amazon" + case .unknown: official = nil + } + guard let id = value ?? official else { return nil } + if let official { return id == official ? id : nil } + guard id.range(of: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", options: .regularExpression) == id.startIndex.. ExternalPurchaseNoticeResultIOS /// Initiate a purchase or subscription flow; rely on events for final state. + /// Providers emit one canonical purchase-error event before returning or throwing a request failure. /// See: https://openiap.dev/docs/apis/request-purchase func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? /// Restore non-consumable and active subscription purchases. diff --git a/packages/apple/Sources/OpenIapGeneratedVersion.swift b/packages/apple/Sources/OpenIapGeneratedVersion.swift index 1e00b8f4e..32b057c4a 100644 --- a/packages/apple/Sources/OpenIapGeneratedVersion.swift +++ b/packages/apple/Sources/OpenIapGeneratedVersion.swift @@ -2,7 +2,7 @@ // Do not edit. enum OpenIapGeneratedVersion { - static let clientProtocol = "0.1.1" + static let clientProtocol = "0.2.0" static let apple = "3.6.1" static let google = "3.6.2" } diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index d27197612..950fd669f 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -118,6 +118,18 @@ import StoreKit } } + @objc func requestPurchaseWithJSON( + _ payloadJSON: String, + completion: @escaping (Any?, Error?) -> Void + ) { + do { + guard let payload = try JSONSerialization.jsonObject(with: Data(payloadJSON.utf8)) as? [String: Any] else { + throw PurchaseError.make(code: .developerError, message: "Purchase request must be a dictionary") + } + requestPurchaseWithPayload(payload, completion: completion) + } catch { completion(nil, error) } + } + @objc func requestPurchaseWithPayload( _ payload: [String: Any], completion: @escaping (Any?, Error?) -> Void @@ -340,6 +352,21 @@ import StoreKit // MARK: - Transaction Management + @objc func finishTransactionWithPurchaseJSON( + _ purchaseJSON: String, + isConsumable: Bool, + completion: @escaping (Error?) -> Void + ) { + Task { + do { + let object = try JSONSerialization.jsonObject(with: Data(purchaseJSON.utf8)) + let purchase = try OpenIapSerialization.purchaseInput(from: object) + try await finishTransaction(purchase: purchase, isConsumable: isConsumable) + completion(nil) + } catch { completion(error) } + } + } + @objc func finishTransactionWithPurchaseId( _ purchaseId: String, productId: String, @@ -348,6 +375,15 @@ import StoreKit ) { Task { do { + if storeId != StoreIds.Apple { + let owned = try await getAvailablePurchases(nil) + guard let purchase = owned.first(where: { $0.id == purchaseId && $0.productId == productId }) else { + throw PurchaseError.make(code: .itemNotOwned, message: "Provider purchase was not found. Pass the full purchase to finishTransactionWithPurchaseJSON.") + } + try await finishTransaction(purchase: purchase, isConsumable: isConsumable) + completion(nil) + return + } print("[OpenIAP] finishTransaction bridge start id=\(purchaseId) product=\(productId)") let minimalPurchase = PurchaseIOS( appAccountToken: nil, @@ -377,6 +413,8 @@ import StoreKit revocationReasonIOS: nil, store: .apple, storefrontCountryCodeIOS: nil, + + storeId: "apple", subscriptionGroupIdIOS: nil, transactionDate: Date().milliseconds, transactionId: purchaseId, @@ -589,9 +627,13 @@ import StoreKit // MARK: - Subscription Management @objc func getActiveSubscriptionsWithCompletion(_ completion: @escaping ([Any]?, Error?) -> Void) { + getActiveSubscriptionsWithSubscriptionIds(nil, completion: completion) + } + + @objc func getActiveSubscriptionsWithSubscriptionIds(_ subscriptionIds: [String]?, completion: @escaping ([Any]?, Error?) -> Void) { Task { do { - let subscriptions = try await getActiveSubscriptions(nil) + let subscriptions = try await getActiveSubscriptions(subscriptionIds) let dictionaries = try subscriptions.map { try OpenIapSerialization.encodeRequired($0) } completion(dictionaries, nil) } catch { @@ -601,9 +643,13 @@ import StoreKit } @objc func hasActiveSubscriptionsWithCompletion(_ completion: @escaping (Bool, Error?) -> Void) { + hasActiveSubscriptionsWithSubscriptionIds(nil, completion: completion) + } + + @objc func hasActiveSubscriptionsWithSubscriptionIds(_ subscriptionIds: [String]?, completion: @escaping (Bool, Error?) -> Void) { Task { do { - let hasActive = try await hasActiveSubscriptions(nil) + let hasActive = try await hasActiveSubscriptions(subscriptionIds) completion(hasActive, nil) } catch { completion(false, error) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index c159efe26..74f810690 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -1,2729 +1,232 @@ import Foundation -import CoreFoundation import StoreKit -private struct IndexedProductEntry: @unchecked Sendable { - let index: Int - let entry: OpenIAP.ProductOrSubscription -} - -struct IapkitAmazonVerificationPayload: Codable { - let store: IapStore - let expectedProductId: String? - let receiptId: String - let sandbox: Bool? - let userId: String? - let includeClientPayload: Bool? -} - -struct IapkitHorizonVerificationPayload: Codable { - let store: IapStore - let sku: String - let userId: String - let includeClientPayload: Bool? -} - -struct EntitlementSelectionKey: Comparable { - let purchaseDate: Date - let transactionId: UInt64 - - static func < (lhs: Self, rhs: Self) -> Bool { - if lhs.purchaseDate != rhs.purchaseDate { - return lhs.purchaseDate < rhs.purchaseDate - } - return lhs.transactionId < rhs.transactionId - } -} -// UIKit: Required for UIApplication, UIWindowScene on iOS/tvOS/visionOS -#if canImport(UIKit) -import UIKit -#endif -// AppKit: Required for NSApplication, NSWindow on macOS -#if canImport(AppKit) -import AppKit -#endif - -/// - SeeAlso: https://developer.apple.com/documentation/storekit/in-app_purchase @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) @objc(OpenIapModule) public final class OpenIapModule: NSObject, OpenIapModuleProtocol { public static let shared = OpenIapModule() + private let selection: Result - static func iapkitVerificationURL(baseUrl: String?) throws -> URL { - let defaultBaseUrl = "https://kit.openiap.dev" - let trimmedBaseUrl = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - var normalizedBaseUrl = trimmedBaseUrl.isEmpty ? defaultBaseUrl : trimmedBaseUrl - - while normalizedBaseUrl.hasSuffix("/") { - normalizedBaseUrl.removeLast() - } - - guard let components = URLComponents(string: normalizedBaseUrl) else { - throw PurchaseError.make( - code: .developerError, - message: "IAPKit baseUrl must be a valid HTTP(S) origin" - ) - } - let hasValidPort = components.port.map { (1...65_535).contains($0) } ?? true - guard let scheme = components.scheme?.lowercased(), - scheme == "http" || scheme == "https", - components.host?.isEmpty == false, - components.user == nil, - components.password == nil, - components.path.isEmpty, - components.query == nil, - components.fragment == nil, - hasValidPort, - let url = URL(string: "\(normalizedBaseUrl)/v1/purchase/verify") else { - throw PurchaseError.make( - code: .developerError, - message: "IAPKit baseUrl must be a valid HTTP(S) origin" - ) - } - - return url - } - - /// Optional enrichment: returns nil for anything this build cannot read, - /// including a `format` IAPKit added later. Never fails the receipt. - static func iapkitClientPayload(from rawValue: Any?) -> IapkitProductClientPayload? { - guard let rawValue, !(rawValue is NSNull) else { return nil } - guard let payload = rawValue as? [String: Any], - let formatString = payload["format"] as? String, - let format = IapkitClientPayloadFormat(rawValue: formatString), - let body = payload["body"] as? String, - let version = payload["version"] as? NSNumber, - let updatedAt = payload["updatedAt"] as? NSNumber, - CFGetTypeID(version) != CFBooleanGetTypeID(), - CFGetTypeID(updatedAt) != CFBooleanGetTypeID(), - version.doubleValue.isFinite, - version.doubleValue > 0, - version.doubleValue.rounded(.towardZero) == version.doubleValue, - updatedAt.doubleValue.isFinite, - updatedAt.doubleValue >= 0 else { - OpenIapLog.warn("Ignoring an IAPKit client payload this build cannot read") - return nil - } - - return IapkitProductClientPayload( - body: body, - format: format, - updatedAt: updatedAt.doubleValue, - version: version.doubleValue - ) - } - - static func makeIapkitRequest( - url: URL, - apiKey: String?, - body: Data - ) -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let trimmedApiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmedApiKey, trimmedApiKey.isEmpty == false { - request.setValue("Bearer \(trimmedApiKey)", forHTTPHeaderField: "Authorization") - } - request.httpBody = body - return request - } - - static func iapkitBoolean(from rawValue: Any?) throws -> Bool { - guard let value = rawValue as? NSNumber, - CFGetTypeID(value) == CFBooleanGetTypeID() else { - throw PurchaseError.make( - code: .purchaseVerificationFailed, - message: "IAPKit returned malformed response" - ) - } - - return value.boolValue - } - - /// Forwarded opaquely: `environment` is String in the spec, and App Store - /// Server also names `Xcode` and `LocalTesting`. - static func iapkitEnvironment(from rawValue: Any?) -> String? { - guard let rawValue, !(rawValue is NSNull) else { return nil } - guard let environment = rawValue as? String, environment.isEmpty == false else { - OpenIapLog.warn("Ignoring an IAPKit environment this build cannot read") - return nil - } - - return environment + public override init() { + selection = OpenIapProvider.select(bundle: .main) + super.init() } - static func iapkitAmazonPayload( - from amazon: RequestVerifyPurchaseWithIapkitAmazonProps, - includeClientPayload: Bool? - ) throws -> IapkitAmazonVerificationPayload { - let receiptId = amazon.receiptId.trimmingCharacters(in: .whitespacesAndNewlines) - guard receiptId.isEmpty == false else { - throw PurchaseError.make(code: .developerError, message: "Amazon receiptId is required") - } - let userId = amazon.userId?.trimmingCharacters(in: .whitespacesAndNewlines) - - return IapkitAmazonVerificationPayload( - store: .amazon, - expectedProductId: amazon.expectedProductId, - receiptId: receiptId, - sandbox: amazon.sandbox, - userId: userId?.isEmpty == true ? nil : userId, - includeClientPayload: includeClientPayload - ) + public init(factory: any OpenIapProviderFactory) { + selection = OpenIapProvider.select(factory: factory) + super.init() } - static func iapkitHorizonPayload( - from horizon: RequestVerifyPurchaseWithIapkitHorizonProps, - includeClientPayload: Bool? - ) throws -> IapkitHorizonVerificationPayload { - let sku = horizon.sku.trimmingCharacters(in: .whitespacesAndNewlines) - let userId = horizon.userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard sku.isEmpty == false, userId.isEmpty == false else { - throw PurchaseError.make( - code: .developerError, - message: "Horizon sku and userId are required" - ) - } - return IapkitHorizonVerificationPayload( - store: .horizon, - sku: sku, - userId: userId, - includeClientPayload: includeClientPayload - ) + init(selection: Result) { + self.selection = selection + super.init() } - /// Objective-C accessor for [OpenIapModule.shared]. Exists so the .NET MAUI - /// binding (`OpenIap.Maui.Bindings.iOS`) can surface the singleton via - /// `[OpenIapModule sharedInstance]`; Swift's static stored properties - /// aren't @objc-bridged automatically. @objc public class func sharedInstance() -> OpenIapModule { shared } - private let state = IapState() - private let connection = OpenIapConnectionLifecycle() - private let messageListenerRegistrationLock = NSLock() - private static let initRetryDelayNanoseconds: UInt64 = 1_000_000 - private static let subscriptionPreflightTimeoutNanoseconds: UInt64 = 750_000_000 - @TaskLocal private static var suppressPurchaseErrorEmission = false - - #if os(iOS) - private let promotedPurchaseObserverLock = NSLock() - private var didRegisterPromotedPurchaseObserver = false - private var isPromotedPurchaseObserverTransitionInFlight = false - private var promotedPurchaseIntentTask: Task? - private let promotedPurchaseIntentOffers = - PromotedPurchaseIntentOfferStore() - #endif - - private enum SubscriptionPreflightOutcome { - case completed - case timedOut - } - - private override init() { - super.init() - startPromotedPurchaseIntentListenerIfAvailableIOS() - registerPromotedPurchaseObserverIfNeeded() - } + @objc public var storeId: String? { try? selection.get().storeId } + public var capabilities: Set { (try? selection.get().capabilities) ?? [] } - deinit { - #if os(iOS) - promotedPurchaseIntentTask?.cancel() - #endif - unregisterPromotedPurchaseObserverIfNeeded() - cancelConnectionTasksForDeinit() + private var provider: any OpenIapModuleProtocol { + get throws { try selection.get().module } } - // MARK: - Connection Management - - /// Initialize the store connection. Must be called before any other IAP API. - /// - /// - Returns: `true` once StoreKit is connected. - /// - Throws: When StoreKit fails to initialize. - /// - Note: This wraps `OpenIapStoreKit2.initialize()`. Safe to call multiple times — the - /// second call is a no-op. - /// - /// See: https://openiap.dev/docs/apis/init-connection public func initConnection() async throws -> Bool { - while true { - if let endTask = connection.currentEndTask() { - await endTask.value - continue - } - - if await hasInitializedConnection() { - return true - } - - guard let initWork = connection.makeInitTask(operation: { [weak self] generation in - guard let self else { return false } - return try await self.performInitConnection(generation: generation) - }) else { - if let endTask = connection.currentEndTask() { - await endTask.value - } else { - try await Task.sleep(nanoseconds: Self.initRetryDelayNanoseconds) - } - continue - } - - do { - let value = try await initWork.task.value - connection.clearInitTask(generation: initWork.generation) - return value - } catch is CancellationError { - connection.clearInitTask(generation: initWork.generation) - return false - } catch { - connection.clearInitTask(generation: initWork.generation) - throw error - } - } + try await provider.initConnection() } - /// Close the store connection and release resources. - /// See: https://openiap.dev/docs/apis/end-connection public func endConnection() async throws -> Bool { - let task = connection.makeEndTask { [weak self] in - guard let self else { return } - await self.cleanupExistingState() - } - await task.value - return true + try await provider.endConnection() } - // MARK: - Product Management - - /// Retrieve products or subscriptions from the App Store by SKU. - /// - /// - Parameter params: `ProductRequest` with `skus` (the product identifiers) and an - /// optional `type` (`.inApp`, `.subs`, or `.all`; defaults to `.inApp` when omitted — - /// matches the cross-platform Flutter / expo-iap / react-native-iap / godot SDKs). - /// - Returns: A `FetchProductsResult` variant — `Product[]` for `.inApp`, - /// `ProductSubscription[]` for `.subs`, or a mixed list for `.all`. - /// - Throws: When the store rejects the request (unknown SKU, network failure, not connected). - /// - Note: This is a regular promise-based call. Do not confuse with `request*` APIs, - /// which are event-based. - /// - /// See: https://openiap.dev/docs/apis/fetch-products public func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult { - guard !params.skus.isEmpty else { - let error = makePurchaseError(code: .emptySkuList) - emitPurchaseError(error) - throw error - } - - try await ensureConnection() - guard let productManager = connection.currentProductManager() else { - let error = makePurchaseError(code: .notPrepared) - emitPurchaseError(error) - throw error - } - - let fetchedProducts: [StoreKit.Product] - do { - fetchedProducts = try await StoreKit.Product.products(for: params.skus) - for product in fetchedProducts { - await productManager.addProduct(product) - } - } catch { - let purchaseError = makePurchaseError( - code: .queryProduct, - message: error.localizedDescription, - debugMessage: error.localizedDescription - ) - emitPurchaseError(purchaseError) - throw purchaseError - } - - // Preserve the concrete product variant so subscription-only fields are - // not discarded when callers request a mixed result. - let allEntries = await withTaskGroup(of: IndexedProductEntry.self) { group in - for (index, product) in fetchedProducts.enumerated() { - group.addTask { - if let subscription = await StoreKitTypesBridge.productSubscription(from: product) { - return IndexedProductEntry( - index: index, - entry: .productSubscription(subscription) - ) - } - - let productEntry = await StoreKitTypesBridge.product(from: product) - return IndexedProductEntry(index: index, entry: .product(productEntry)) - } - } - - var entries = Array( - repeating: nil, - count: fetchedProducts.count - ) - for await result in group { - entries[result.index] = result.entry - } - return entries.compactMap { $0 } - } - - var productEntries: [OpenIAP.Product] = [] - var subscriptionEntries: [OpenIAP.ProductSubscription] = [] - for entry in allEntries { - switch entry { - case .product(let product): - productEntries.append(product) - case .productSubscription(let subscription): - subscriptionEntries.append(subscription) - } - } - - switch params.type ?? .inApp { - case .subs: - return .subscriptions(subscriptionEntries.isEmpty ? nil : subscriptionEntries) - case .inApp: - return .products(productEntries.isEmpty ? nil : productEntries) - case .all: - return .all(allEntries.isEmpty ? nil : allEntries) - } + try await provider.fetchProducts(params) } - /// Read the App Store-promoted product, if any. - /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios public func getPromotedProductIOS() async throws -> ProductIOS? { - // iOS-only: Promoted in-app purchases (App Store promotional purchases) only available on iOS - // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases - #if os(iOS) - let sku = await state.promotedProductIdentifier() - guard let sku else { return nil } - - do { - try await ensureConnection() - } catch let purchaseError as PurchaseError { - throw purchaseError - } - - await state.setPromotedProductId(sku) - - do { - let product = try await storeProduct(for: sku) - return await StoreKitTypesBridge.productIOS(from: product) - } catch let purchaseError as PurchaseError { - await state.setPromotedProductId(nil) - throw purchaseError - } catch { - let wrapped = makePurchaseError( - code: .queryProduct, - productId: sku, - message: error.localizedDescription, - debugMessage: error.localizedDescription - ) - emitPurchaseError(wrapped) - await state.setPromotedProductId(nil) - throw wrapped - } - #else - return nil - #endif // os(iOS) + try await provider.getPromotedProductIOS() } - // MARK: - Purchase Management - - /// Initiate a purchase flow. The result is delivered via the purchase update listener, - /// NOT through the return value. - /// - /// - Parameter params: `RequestPurchaseProps` — discriminated by `type`: - /// `.inApp` for one-time products (use `request.apple.sku`), `.subs` for subscriptions. - /// - Returns: The dispatched request payload. Do not rely on this for the purchase outcome. - /// - Throws: Synchronous rejections from StoreKit (e.g. user cancel before sheet, not prepared). - /// - Warning: Event-based. Listen via `purchaseUpdatedListener` / `purchaseErrorListener`. - /// - /// See: https://openiap.dev/docs/apis/request-purchase public func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { - let fallbackProductId = purchaseProductId(from: params) - do { - return try await Self.$suppressPurchaseErrorEmission.withValue(true) { - try await performRequestPurchase(params) - } - } catch let purchaseError as PurchaseError { - var canonicalError = purchaseError - if canonicalError.productId == nil { - canonicalError.productId = fallbackProductId - } - emitPurchaseError(canonicalError) - throw canonicalError - } catch { - let canonicalError = PurchaseError.wrap( - error, - fallback: .purchaseError, - productId: fallbackProductId - ) - emitPurchaseError(canonicalError) - throw canonicalError - } - } - - /// Performs the StoreKit request while helper-level error emissions are - /// suppressed. `requestPurchase` emits the final canonical error exactly once. - private func performRequestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { - let iosProps = try resolveIOSPurchaseProps(from: params) - try await ensureConnection() - let sku = iosProps.sku - let product = try await storeProduct(for: sku) - #if os(iOS) - let canUsePurchaseIntentOffer: Bool - if let subscriptionProps = iosProps as? RequestSubscriptionIosProps { - canUsePurchaseIntentOffer = subscriptionProps.winBackOffer == nil && - subscriptionProps.withOffer == nil && - subscriptionProps.promotionalOfferJWS == nil - } else { - canUsePurchaseIntentOffer = false - } - let purchaseIntentOfferLease = canUsePurchaseIntentOffer - ? await promotedPurchaseIntentOffers.lease(for: sku) - : nil - let purchaseIntentOffer = purchaseIntentOfferLease?.offer - #else - let purchaseIntentOffer: StoreKit.Product.SubscriptionOffer? = nil - #endif - let options: Set - do { - options = try StoreKitTypesBridge.purchaseOptionsIOS( - from: iosProps, - product: product, - purchaseIntentOffer: purchaseIntentOffer - ) - } catch { - #if os(iOS) - if let purchaseIntentOfferLease { - await promotedPurchaseIntentOffers.release( - purchaseIntentOfferLease, - for: sku - ) - } - #endif - throw error - } - - if StoreKitTypesBridge.isAutoRenewingSubscriptionProductType(product.type) { - await preflightInactiveUnfinishedSubscriptions(productId: sku) - } - - let result: StoreKit.Product.PurchaseResult - do { - // iOS 17.0+, tvOS 17.0+, macOS 15.2+: Use purchase(confirmIn:options:) for better purchase confirmation UI - // Reference: https://developer.apple.com/documentation/storekit/product/purchase(confirmin:options:)-6dj6y - #if os(iOS) || os(tvOS) || os(visionOS) - // iOS/tvOS/visionOS: Use UIWindowScene (not available on watchOS) - if #available(iOS 17.0, tvOS 17.0, visionOS 1.0, *) { - result = try await purchaseWithActiveScene(product: product, options: options, sku: sku) - } else { - result = try await product.purchase(options: options) - } - #elseif os(macOS) - // macOS: Use NSWindow (macOS 15.2+) - if #available(macOS 15.2, *) { - let window: NSWindow? = await MainActor.run { - NSApplication.shared.windows.first - } - guard let window else { - let error = makePurchaseError(code: .purchaseError, message: "Could not find window") - throw error - } - result = try await product.purchase(confirmIn: window, options: options) - } else { - result = try await product.purchase(options: options) - } - #else - result = try await product.purchase(options: options) - #endif - } catch { - #if os(iOS) - if let purchaseIntentOfferLease { - await promotedPurchaseIntentOffers.release( - purchaseIntentOfferLease, - for: sku - ) - } - #endif - // Enhanced error handling for promotional offers - if iosProps.withOffer != nil { - OpenIapLog.error("Purchase with promotional offer failed: \(error.localizedDescription)") - let enhancedMessage = """ - Promotional offer purchase failed: \(error.localizedDescription) - - Common causes: - 1. Invalid signature - verify server generates correct signature with exact parameter order - 2. Empty appAccountToken - ensure empty string ('') is used in signature, not null - 3. Sandbox testing - ensure current subscription has expired before testing offers - 4. Offer eligibility - user may not be eligible for this promotional offer - """ - let purchaseError = makePurchaseError( - code: .purchaseError, - productId: sku, - message: enhancedMessage, - debugMessage: error.localizedDescription - ) - throw purchaseError - } - - // Use PurchaseError.wrap to automatically map errors (including StoreKitError.userCancelled) - let purchaseError = PurchaseError.wrap(error, fallback: .purchaseError, productId: sku) - throw purchaseError - } - - // Keep the intent offer available across local validation and - // presentation-context failures. Consume only after StoreKit has - // accepted the matching purchase attempt and returned a result. - #if os(iOS) - if let purchaseIntentOfferLease { - await promotedPurchaseIntentOffers.consume( - purchaseIntentOfferLease, - for: sku - ) - } - #endif - - switch result { - case .success(let verification): - let transaction = try checkVerified(verification) - if StoreKitTypesBridge.isAutoRenewingSubscriptionProductType(product.type), - isInactiveSubscriptionTransaction(transaction) { - await transaction.finish() - await state.removePending(id: String(transaction.id)) - OpenIapLog.debug(""" - 🧹 [requestPurchase] Finished inactive subscription transaction before emitting: - - SKU: \(transaction.productID) - - Transaction ID: \(transaction.id) - - Expiration: \(transaction.expirationDate?.description ?? "none") - - Revoked: \(transaction.revocationDate?.description ?? "none") - - Upgraded: \(transaction.isUpgraded) - """) - let error = makePurchaseError( - code: .purchaseError, - productId: sku, - message: "Finished an inactive subscription transaction. Please retry the purchase." - ) - throw error - } - let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) - let transactionId = String(transaction.id) - let shouldAutoFinish = iosProps.andDangerouslyFinishTransactionAutomatically == true - - let isSubscription = StoreKitTypesBridge.isAutoRenewingSubscriptionProductType(product.type) - - OpenIapLog.debug(""" - 🎯 [requestPurchase] Purchase successful: - - Requested SKU: \(sku) - - Returned Product: \(transaction.productID) - - Transaction ID: \(transactionId) - - Purchase Date: \(transaction.purchaseDate) - - Product Type: \(isSubscription ? "subscription" : "non-subscription") - - SKU matches: \(transaction.productID == sku) - - Note: \(isSubscription ? "Subscription transactions will be emitted via Transaction.updates" : "Emitting directly") - """) - - let shouldEmit = await state.recordPurchaseUpdateEmission( - id: transactionId, - pendingTransaction: shouldAutoFinish ? nil : transaction - ) - // Dedupe only controls listener delivery; auto-finish must still - // complete the StoreKit transaction lifecycle for replayed updates. - if shouldAutoFinish { - await transaction.finish() - await state.removePending(id: transactionId) - } - - // StoreKit can replay unfinished transactions through multiple paths during a - // connection session. Default listeners receive each transaction id once; - // non-deduping listeners can opt into the replay for diagnostics. - emitPurchaseUpdate( - purchase, - isDuplicate: !shouldEmit, - duplicateSource: "requestPurchase", - duplicateTransactionId: transactionId - ) - - return .purchase(purchase) - - case .userCancelled: - let error = makePurchaseError(code: .userCancelled, productId: sku) - throw error - - case .pending: - let error = makePurchaseError(code: .deferredPayment, productId: sku) - throw error - - @unknown default: - let error = makePurchaseError(code: .unknown, productId: sku) - throw error - } + try await provider.requestPurchase(params) } - /// Restore non-consumable and active subscription purchases. - /// See: https://openiap.dev/docs/apis/restore-purchases public func restorePurchases() async throws -> Void { - _ = try await syncIOS() + try await provider.restorePurchases() } - /// List the user's purchases held by StoreKit. By default reads `Transaction.all` (the - /// full history including refunded / revoked entries). Pass - /// `onlyIncludeActiveItemsIOS = true` to switch to `Transaction.currentEntitlements`, - /// which narrows the result to active non-consumables and live subscriptions. - /// - /// - Parameter options: Optional iOS-specific flags. `onlyIncludeActiveItemsIOS` - /// toggles between `Transaction.all` (default) and `Transaction.currentEntitlements`. - /// `alsoPublishToEventListenerIOS` re-emits each purchase on the update listener. - /// - Returns: An array of `Purchase` values matching the selected scope. - /// - Throws: When the StoreKit query fails. - /// - /// See: https://openiap.dev/docs/apis/get-available-purchases public func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] { - try await ensureConnection() - let onlyActive = options?.onlyIncludeActiveItemsIOS ?? false - let shouldPublish = options?.alsoPublishToEventListenerIOS ?? false - var purchasedItems: [Purchase] = [] - - for await verification in (onlyActive ? Transaction.currentEntitlements : Transaction.all) { - let transaction = try checkVerified(verification) - - if onlyActive, let expirationDate = transaction.expirationDate, expirationDate <= Date() { - continue - } - - let purchase = await StoreKitTypesBridge.purchase( - from: transaction, - jwsRepresentation: verification.jwsRepresentation - ) - purchasedItems.append(purchase) - } - - // Publish only after the complete sequence verifies successfully so a - // failed query cannot leak a partial authoritative result via events. - if shouldPublish { - purchasedItems.forEach { emitPurchaseUpdate($0) } - } - - OpenIapLog.debug("🔍 getAvailablePurchases: \(purchasedItems.count) purchases (onlyActive=\(onlyActive))") - return purchasedItems + try await provider.getAvailablePurchases(options) } - /// Get the full StoreKit 2 transaction history as `PurchaseIOS` values. - /// Requires the SKIncludeConsumableInAppPurchaseHistory Info.plist key in the host app - /// for finished consumables to be included in `Transaction.all` (iOS 18+). - /// Unlike `getAvailablePurchases(_:)`, this method always reads from - /// `Transaction.all` and returns the iOS-specific `PurchaseIOS` shape rather than - /// the cross-platform `Purchase` type. - /// - /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios public func getAllTransactionsIOS() async throws -> [PurchaseIOS] { - try await ensureConnection() - var transactions: [PurchaseIOS] = [] - - for await verification in Transaction.all { - let transaction = try checkVerified(verification) - let purchase = await StoreKitTypesBridge.purchaseIOS( - from: transaction, - jwsRepresentation: verification.jwsRepresentation - ) - transactions.append(purchase) - } - - OpenIapLog.debug("🔍 getAllTransactionsIOS: \(transactions.count) transactions") - return transactions + try await provider.getAllTransactionsIOS() } - // MARK: - Transaction Management - - /// Complete a purchase transaction. Call after server-side verification to remove it - /// from the StoreKit queue. - /// - /// - Parameters: - /// - purchase: The `PurchaseInput` to finalize. - /// - isConsumable: Pass `true` for consumables (re-buyable like coins), `false` for - /// subscriptions and non-consumables. Affects only Android consume vs acknowledge; - /// iOS always calls `Transaction.finish()`. - /// - Throws: When the platform finalization fails. - /// - Important: iOS unfinished transactions replay on every app launch. (Android purchases - /// must be acknowledged within 3 days, but that path lives in the Android module.) - /// - /// See: https://openiap.dev/docs/apis/finish-transaction public func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws -> Void { - try await ensureConnection() - let identifier = purchase.id - - if let pending = await state.getPending(id: identifier) { - await pending.finish() - await state.removePending(id: identifier) - return + let active = try provider + guard case .purchaseIos = purchase else { + throw PurchaseError.make(code: .developerError, message: "Apple providers require an iOS purchase.") } - - guard let numericId = UInt64(identifier) else { - let error = makePurchaseError(code: .purchaseError, message: "Invalid transaction identifier") - emitPurchaseError(error) - throw error - } - - for await result in Transaction.unfinished { - do { - let transaction = try checkVerified(result) - if transaction.id == numericId { - await transaction.finish() - await state.removePending(id: identifier) - return - } - } catch { - OpenIapLog.debug("⚠️ finishTransaction unfinished lookup failed: \(error.localizedDescription)") - } + guard purchase.storeId == storeId else { + throw PurchaseError.make(code: .developerError, message: "Purchase storeId differs from the selected provider.") } - - if let result = await Transaction.latest(for: purchase.productId) { - do { - let transaction = try checkVerified(result) - if transaction.id == numericId { - await transaction.finish() - await state.removePending(id: identifier) - return - } - } catch { - OpenIapLog.debug("⚠️ finishTransaction latest lookup failed: \(error.localizedDescription)") - } - } - - OpenIapLog.debug("ℹ️ finishTransaction skipped: transaction \(identifier) is not pending/unfinished/latest; treating as already finished") + try await active.finishTransaction(purchase: purchase, isConsumable: isConsumable) } - /// List unfinished StoreKit transactions. - /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios public func getPendingTransactionsIOS() async throws -> [PurchaseIOS] { - try await ensureConnection() - var purchases: [PurchaseIOS] = [] - for await verification in Transaction.unfinished { - let transaction = try checkVerified(verification) - await state.storePending(id: String(transaction.id), transaction: transaction) - purchases.append( - await StoreKitTypesBridge.purchaseIOS( - from: transaction, - jwsRepresentation: verification.jwsRepresentation - ) - ) - } - return purchases + try await provider.getPendingTransactionsIOS() } - /// Clear pending transactions in the queue (sandbox helper). - /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios public func clearTransactionIOS() async throws -> Bool { - try await ensureConnection() - for await result in Transaction.unfinished { - do { - let transaction = try checkVerified(result) - await transaction.finish() - await state.removePending(id: String(transaction.id)) - } catch { - continue - } - } - return true + try await provider.clearTransactionIOS() } - /// Check whether a transaction's JWS verification passed. - /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios public func isTransactionVerifiedIOS(sku: String) async throws -> Bool { - try await ensureConnection() - let product = try await storeProduct(for: sku) - guard let result = await product.latestTransaction else { return false } - do { - _ = try checkVerified(result) - return true - } catch { - return false - } + try await provider.isTransactionVerifiedIOS(sku: sku) } - /// Return the JWS string for a transaction. - /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios public func getTransactionJwsIOS(sku: String) async throws -> String? { - try await ensureConnection() - let product = try await storeProduct(for: sku) - guard let result = await product.latestTransaction else { - let error = makePurchaseError(code: .skuNotFound, productId: sku) - emitPurchaseError(error) - throw error - } - return result.jwsRepresentation + try await provider.getTransactionJwsIOS(sku: sku) } - // MARK: - Validation - - /// Get base64 receipt data (legacy validation). - /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios - public func getReceiptDataIOS() async throws -> String? { - guard let receiptURL = Bundle.main.appStoreReceiptURL, - FileManager.default.fileExists(atPath: receiptURL.path) else { - return nil - } - let data = try Data(contentsOf: receiptURL) - return data.base64EncodedString() + public func currentEntitlementIOS(sku: String) async throws -> PurchaseIOS? { + try await provider.currentEntitlementIOS(sku: sku) } - private func performVerifyPurchaseIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS { - let receiptData = (try? await getReceiptDataIOS()) ?? "" - var latestPurchase: Purchase? = nil - var jws: String = "" - var isValid = false - - // Apple options with sku is required - guard let appleOptions = props.apple, !appleOptions.sku.isEmpty else { - throw makePurchaseError( - code: .developerError, - message: "Apple verification requires apple options with sku" - ) - } - - do { - let product = try await storeProduct(for: appleOptions.sku) - if let result = await product.latestTransaction { - jws = result.jwsRepresentation - let transaction = try checkVerified(result) - latestPurchase = .purchaseIos(await StoreKitTypesBridge.purchaseIOS(from: transaction, jwsRepresentation: result.jwsRepresentation)) - isValid = true - } - } catch { - isValid = false - } + public func latestTransactionIOS(sku: String) async throws -> PurchaseIOS? { + try await provider.latestTransactionIOS(sku: sku) + } - return VerifyPurchaseResultIOS( - isValid: isValid, - jwsRepresentation: jws, - latestTransaction: latestPurchase, - receiptData: receiptData - ) + public func getReceiptDataIOS() async throws -> String? { + try await provider.getReceiptDataIOS() } - /// Verify a purchase against your own backend (returns isValid + raw store metadata). - /// See: https://openiap.dev/docs/features/validation#verify-purchase public func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { - try await ensureConnection() - let iosResult = try await performVerifyPurchaseIOS(props) - return .verifyPurchaseResultIos(iosResult) + try await provider.verifyPurchase(props) } - /// Verify via a managed provider (currently IAPKit; the PurchaseVerificationProvider enum exposes only Iapkit today). - /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider public func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { - struct IapkitApplePayload: Codable { - let store: IapStore - let jws: String - let includeClientPayload: Bool? - } - struct IapkitGooglePayload: Codable { - let store: IapStore - let purchaseToken: String - let includeClientPayload: Bool? - } - - func extractIapkitErrorMessage(from json: [String: Any]) -> String? { - func extractStringMessage(_ value: String) -> String { - if let data = value.data(using: .utf8), - let nested = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return extractIapkitErrorMessage(from: nested) ?? value - } - return value - } - - if let details = json["details"] as? [String: Any], - let originalError = details["originalError"] as? String { - return extractStringMessage(originalError) - } - - if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { - return extractIapkitErrorMessage(from: firstError) - } - - if let message = json["message"] as? String { - return extractStringMessage(message) - } - - if let error = json["error"] as? String { - return extractStringMessage(error) - } - - return nil - } - - func buildIapkitPayload(props: RequestVerifyPurchaseWithIapkitProps) throws -> (store: IapStore, body: Data) { - let encoder = JSONEncoder() - encoder.outputFormatting = [.withoutEscapingSlashes] - let payloadCount = [ - props.apple != nil, - props.google != nil, - props.horizon != nil, - props.amazon != nil, - ].filter { $0 }.count - guard payloadCount == 1 else { - throw makePurchaseError(code: .developerError, message: "IAPKit verification requires exactly one store payload") - } - - if let apple = props.apple { - let jws = apple.jws.trimmingCharacters(in: .whitespacesAndNewlines) - guard jws.isEmpty == false else { - throw makePurchaseError(code: .developerError, message: "JWS is required") - } - let payload = IapkitApplePayload( - store: .apple, - jws: jws, - includeClientPayload: props.includeClientPayload - ) - return (.apple, try encoder.encode(payload)) - } - - if let google = props.google { - let purchaseToken = google.purchaseToken.trimmingCharacters(in: .whitespacesAndNewlines) - guard purchaseToken.isEmpty == false else { - throw makePurchaseError(code: .developerError, message: "Google purchase token is required") - } - let payload = IapkitGooglePayload( - store: .google, - purchaseToken: purchaseToken, - includeClientPayload: props.includeClientPayload - ) - return (.google, try encoder.encode(payload)) - } - - if let horizon = props.horizon { - let payload = try Self.iapkitHorizonPayload( - from: horizon, - includeClientPayload: props.includeClientPayload - ) - return (.horizon, try encoder.encode(payload)) - } - - if let amazon = props.amazon { - let payload = try Self.iapkitAmazonPayload( - from: amazon, - includeClientPayload: props.includeClientPayload - ) - return (.amazon, try encoder.encode(payload)) - } - - throw makePurchaseError(code: .developerError, message: "IAPKit verification payload is required") - } - - func verifyPurchaseWithIapkit(props: RequestVerifyPurchaseWithIapkitProps) async throws -> RequestVerifyPurchaseWithIapkitResult { - let url = try Self.iapkitVerificationURL(baseUrl: props.baseUrl) - - let payload = try buildIapkitPayload(props: props) - let store = payload.store - let body = payload.body - - let request = Self.makeIapkitRequest(url: url, apiKey: props.apiKey, body: body) - - OpenIapLog.debug("IAPKit request URL: \(url.absoluteString)") - OpenIapLog.debug("IAPKit request body bytes=\(body.count)") - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch is CancellationError { - throw CancellationError() - } catch { - if let urlError = error as? URLError, urlError.code == .cancelled { - throw CancellationError() - } - OpenIapLog.warn("IAPKit verification network error: \(error.localizedDescription)") - throw makePurchaseError(code: .networkError, message: error.localizedDescription) - } - guard let httpResponse = response as? HTTPURLResponse else { - throw makePurchaseError(code: .networkError, message: "Invalid response") - } - guard (200...299).contains(httpResponse.statusCode) else { - let responseBody = String(data: data, encoding: .utf8) ?? "" - OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP \(httpResponse.statusCode))") - var errorMessage = "HTTP \(httpResponse.statusCode)" - if let jsonData = responseBody.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { - errorMessage = extractIapkitErrorMessage(from: json) ?? errorMessage - } - throw makePurchaseError(code: .purchaseVerificationFailed, message: errorMessage) - } - - OpenIapLog.debug("IAPKit verification response received: bytes=\(data.count)") - - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - OpenIapLog.warn("Failed to parse IAPKit verification response") - throw makePurchaseError(code: .purchaseVerificationFailed, message: "Unable to parse verification response") - } - - if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { - let errorMessage = extractIapkitErrorMessage(from: firstError) ?? "Unknown error" - let errorCode = firstError["code"] as? String ?? "unknown" - OpenIapLog.warn("IAPKit verification error: \(errorCode) - \(errorMessage)") - throw makePurchaseError(code: .purchaseVerificationFailed, message: errorMessage) - } - - let isValid = try Self.iapkitBoolean(from: json["isValid"]) - guard let stateString = json["state"] as? String, - let storeString = json["store"] as? String, - let parsedStore = IapStore(rawValue: storeString), - parsedStore == store else { - OpenIapLog.warn("IAPKit verification response missing required fields") - throw makePurchaseError(code: .purchaseVerificationFailed, message: "IAPKit returned malformed response") - } - let normalizedState = stateString.lowercased().replacingOccurrences(of: "_", with: "-") - let parsedState = IapkitPurchaseState(rawValue: normalizedState) ?? .unknown - let productId: String? - if let rawProductId = json["productId"], !(rawProductId is NSNull) { - guard let value = rawProductId as? String else { - OpenIapLog.warn("IAPKit verification response contains a non-string productId") - throw makePurchaseError(code: .purchaseVerificationFailed, message: "IAPKit returned malformed response") - } - productId = value - } else { - productId = nil - } - let environment = Self.iapkitEnvironment(from: json["environment"]) - let clientPayload = Self.iapkitClientPayload(from: json["clientPayload"]) - OpenIapLog.info("IAPKit verification result: store=\(parsedStore.rawValue), isValid=\(isValid), state=\(parsedState.rawValue)") - return RequestVerifyPurchaseWithIapkitResult( - clientPayload: clientPayload, - environment: environment, - isValid: isValid, - productId: productId, - state: parsedState, - store: parsedStore - ) - } - try await ensureConnection() - guard props.provider == .iapkit else { - throw makePurchaseError(code: .featureNotSupported, message: "Provider \(props.provider.rawValue) is not supported") - } - guard let iapkit = props.iapkit else { - throw makePurchaseError(code: .developerError, message: "Missing IAPKit verification parameters") - } - let result = try await verifyPurchaseWithIapkit(props: iapkit) - return VerifyPurchaseWithProviderResult( - iapkit: result, - provider: props.provider - ) + try await provider.verifyPurchaseWithProvider(props) } - // MARK: - Store Information - - /// Return the user's storefront country code. - /// See: https://openiap.dev/docs/apis/get-storefront public func getStorefront() async throws -> String { - try await ensureConnection() - guard let storefront = await Storefront.current else { - let error = makePurchaseError(code: .unknown) - emitPurchaseError(error) - throw error - } - return storefront.countryCode + try await provider.getStorefront() } - /// Get the app transaction that represents the user's purchase of the app - /// - Note: Available on iOS 16.0+, macOS 14.0+, tvOS 16.0+, watchOS 9.0+ - /// - SeeAlso: https://developer.apple.com/documentation/storekit/apptransaction - /// - /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios @available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *) public func getAppTransactionIOS() async throws -> AppTransaction? { - try await ensureConnection() - let verification = try await StoreKit.AppTransaction.shared - switch verification { - case .verified(let transaction): - return mapAppTransaction(transaction) - case .unverified: - return nil - } + try await provider.getAppTransactionIOS() } - // MARK: - Subscription Management - - /// Get details of all currently active subscriptions. - /// See: https://openiap.dev/docs/apis/get-active-subscriptions public func getActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] { - try await ensureConnection() - var allSubscriptions: [ActiveSubscription] = [] - for await verification in Transaction.currentEntitlements { - let transaction = try checkVerified(verification) - guard StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( - transaction.productType - ) else { - continue - } - - // Skip upgraded subscriptions - they've been replaced - if transaction.isUpgraded { - continue - } - - if let ids = subscriptionIds, ids.contains(transaction.productID) == false { - continue - } - let expiration = transaction.expirationDate - // If expiration date is nil, treat as inactive (expired or invalid) - // This prevents treating subscriptions without expiration dates as active - let isActive = expiration.map { $0 > Date() } ?? false - let dayDelta = expiration.map { Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 0 } - let daysUntilExpiration = dayDelta.map { Double($0) } - let environment: String? - // OpenIapModule already requires tvOS 16, so only the lower - // iOS and watchOS deployment floors need a runtime check here. - if #available(iOS 16.0, watchOS 9.0, *) { - environment = transaction.environment.rawValue - } else { - environment = nil - } - - // Fetch renewal info for subscription - let renewalInfo = await StoreKitTypesBridge.subscriptionRenewalInfoIOS(for: transaction) - - allSubscriptions.append( - ActiveSubscription( - autoRenewingAndroid: nil, - currentPlanId: transaction.productID, - daysUntilExpirationIOS: daysUntilExpiration, - environmentIOS: environment, - expirationDateIOS: expiration?.milliseconds, - isActive: isActive, - productId: transaction.productID, - purchaseToken: verification.jwsRepresentation, - renewalInfoIOS: renewalInfo, - transactionDate: transaction.purchaseDate.milliseconds, - transactionId: String(transaction.id) - ) - ) - } - - OpenIapLog.debug("📊 Returning \(allSubscriptions.count) active subscriptions") - - // Upgraded subscriptions are already filtered out by transaction.isUpgraded check - // Return all remaining subscriptions (active, downgraded, and cancelled) - return allSubscriptions + try await provider.getActiveSubscriptions(subscriptionIds) } - /// Check whether the user has any active subscription. - /// See: https://openiap.dev/docs/apis/has-active-subscriptions public func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool { - let subscriptions = try await getActiveSubscriptions(subscriptionIds) - return subscriptions.contains { $0.isActive } - } - - /// Show the subscription management interface - /// - Note: Available on iOS 15.0+, iPadOS 15.0+, Mac Catalyst 15.0+, macOS 14.0+, visionOS 1.0+. Not available on tvOS (subscriptions are managed in Settings > Accounts) or watchOS. - /// - Note: macOS has no native StoreKit manage-subscriptions sheet, so this opens https://apps.apple.com/account/subscriptions in the default browser. - /// - SeeAlso: https://developer.apple.com/documentation/storekit/appstore/showmanagesubscriptions(in:) - /// - /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions - public func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { - try await ensureConnection() - // tvOS: AppStore.showManageSubscriptions not available on tvOS (subscriptions managed in Settings > Accounts) - // watchOS: No window scene UI for showManageSubscriptions - #if !os(tvOS) && !os(watchOS) - #if canImport(UIKit) - let scene: UIWindowScene? = await MainActor.run { - UIApplication.shared.connectedScenes.first as? UIWindowScene - } - guard let scene else { - throw makePurchaseError(code: .unknown) - } - try await AppStore.showManageSubscriptions(in: scene) - #elseif canImport(AppKit) - // macOS: AppStore.showManageSubscriptions requires a UIWindowScene, which - // does not exist on macOS. Fall back to opening the App Store - // subscriptions management page in the default browser. - guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { - throw makePurchaseError(code: .unknown, message: "Invalid subscriptions management URL") - } - let opened = await MainActor.run { - NSWorkspace.shared.open(url) - } - guard opened else { - throw makePurchaseError(code: .unknown, message: "Failed to open subscriptions management page") - } - #endif - #else - throw makePurchaseError(code: .featureNotSupported) - #endif // !os(tvOS) && !os(watchOS) + try await provider.hasActiveSubscriptions(subscriptionIds) } - /// Get subscription status objects from StoreKit 2. - /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios public func subscriptionStatusIOS(sku: String) async throws -> [SubscriptionStatusIOS] { - try await ensureConnection() - let product = try await storeProduct(for: sku) - guard let subscription = product.subscription else { - let error = makePurchaseError(code: .skuNotFound, productId: sku) - emitPurchaseError(error) - throw error - } - - do { - let statuses = try await subscription.status - return statuses.map { status in - let renewalInfo: RenewalInfoIOS? - switch status.renewalInfo { - case .verified(let info): - let jsonString = String(data: info.jsonRepresentation, encoding: .utf8) ?? info.jsonRepresentation.base64EncodedString() - renewalInfo = RenewalInfoIOS( - autoRenewPreference: info.autoRenewPreference, - commitmentInfo: StoreKitTypesBridge.renewalCommitmentInfoIOS(from: info), - isInBillingRetry: info.isInBillingRetry, - jsonRepresentation: jsonString, - renewalBillingPlanType: StoreKitTypesBridge.renewalBillingPlanTypeIOS(from: info), - willAutoRenew: info.willAutoRenew - ) - case .unverified: - renewalInfo = nil - } - return SubscriptionStatusIOS( - renewalInfo: renewalInfo, - state: String(describing: status.state) - ) - } - } catch { - let purchaseError = makePurchaseError(code: .serviceError, message: error.localizedDescription) - emitPurchaseError(purchaseError) - throw purchaseError - } + try await provider.subscriptionStatusIOS(sku: sku) } - /// Get the user's current entitlement for a product. - /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios - public func currentEntitlementIOS(sku: String) async throws -> PurchaseIOS? { - try await ensureConnection() - let product = try await storeProduct(for: sku) - - let entitlements: Transaction.Transactions - // Product.currentEntitlements ships in the Xcode 26 SDK and is - // back-deployed by StoreKit. Xcode 16.4 also uses Swift 6.1, so the - // compiler guard must stay at Swift 6.2+ to keep that SDK buildable. - #if compiler(>=6.2) - if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { - entitlements = product.currentEntitlements - } else { - entitlements = Transaction.currentEntitlements - } - #else - entitlements = Transaction.currentEntitlements - #endif - - var latest: ( - key: EntitlementSelectionKey, - transaction: StoreKit.Transaction, - jwsRepresentation: String - )? - for await result in entitlements { - guard result.unsafePayloadValue.productID == sku else { continue } - do { - let transaction = try checkVerified(result) - let key = EntitlementSelectionKey( - purchaseDate: transaction.purchaseDate, - transactionId: transaction.id - ) - if latest.map({ $0.key < key }) ?? true { - latest = (key, transaction, result.jwsRepresentation) - } - } catch { - let error = makePurchaseError( - code: .transactionValidationFailed, - message: error.localizedDescription - ) - emitPurchaseError(error) - throw error - } - } - guard let latest else { return nil } - return await StoreKitTypesBridge.purchaseIOS( - from: latest.transaction, - jwsRepresentation: latest.jwsRepresentation - ) - } - - /// Get the latest verified transaction for a product. - /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios - public func latestTransactionIOS(sku: String) async throws -> PurchaseIOS? { - try await ensureConnection() - let product = try await storeProduct(for: sku) - guard let result = await product.latestTransaction else { return nil } - do { - let transaction = try checkVerified(result) - return await StoreKitTypesBridge.purchaseIOS(from: transaction, jwsRepresentation: result.jwsRepresentation) - } catch { - let error = makePurchaseError(code: .transactionValidationFailed, message: error.localizedDescription) - emitPurchaseError(error) - throw error - } + public func isEligibleForIntroOfferIOS(groupID: String) async throws -> Bool { + try await provider.isEligibleForIntroOfferIOS(groupID: groupID) } - // MARK: - Refunds - - /// Begin a refund request for a transaction - /// - Note: Available on iOS 15.0+, iPadOS 15.0+, Mac Catalyst 15.0+, macOS 12.0+, visionOS 1.0+. Not available on tvOS or watchOS. - /// - Note: macOS has no native StoreKit refund sheet, so this opens https://reportaproblem.apple.com in the default browser and returns nil (the refund is resolved outside the app). - /// - SeeAlso: https://developer.apple.com/documentation/storekit/transaction/3803220-beginrefundrequest - /// - /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios public func beginRefundRequestIOS(sku: String) async throws -> String? { - try await ensureConnection() - // tvOS: Transaction.beginRefundRequest not available on tvOS - // watchOS: Transaction.beginRefundRequest not available on watchOS - #if !os(tvOS) && !os(watchOS) - let product = try await storeProduct(for: sku) - guard let result = await product.latestTransaction else { - let error = makePurchaseError(code: .skuNotFound, productId: sku) - emitPurchaseError(error) - throw error - } - - let transaction = try checkVerified(result) - - #if canImport(UIKit) - let scene: UIWindowScene? = await MainActor.run { - UIApplication.shared.connectedScenes.first as? UIWindowScene - } - guard let scene else { - let error = makePurchaseError(code: .purchaseError, message: "Cannot find window scene") - emitPurchaseError(error) - throw error - } - let status = try await transaction.beginRefundRequest(in: scene) - switch status { - case .success: - return "success" - case .userCancelled: - return "userCancelled" - @unknown default: - return nil - } - #elseif canImport(AppKit) - // macOS: Transaction.beginRefundRequest requires a UIWindowScene, which does - // not exist on macOS. Fall back to opening Apple's refund request page in the - // default browser for the verified transaction. The refund is resolved outside - // the app, so return nil (no definitive status), matching @unknown default. - OpenIapLog.debug("Opening refund request page for transaction \(transaction.id)") - guard let url = URL(string: "https://reportaproblem.apple.com/") else { - let error = makePurchaseError(code: .purchaseError, message: "Invalid refund request URL") - emitPurchaseError(error) - throw error - } - let opened = await MainActor.run { - NSWorkspace.shared.open(url) - } - guard opened else { - let error = makePurchaseError(code: .purchaseError, message: "Failed to open refund request page") - emitPurchaseError(error) - throw error - } - return nil - #endif - #else - throw makePurchaseError(code: .featureNotSupported) - #endif // !os(tvOS) && !os(watchOS) + try await provider.beginRefundRequestIOS(sku: sku) } - // MARK: - Misc - - /// Check if the user is eligible for an introductory offer for a subscription group - /// - SeeAlso: https://developer.apple.com/documentation/storekit/product/subscriptioninfo/iseligibleforintrooffer(for:) - /// - /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios - public func isEligibleForIntroOfferIOS(groupID: String) async throws -> Bool { - let normalizedGroupID = groupID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedGroupID.isEmpty else { - return false - } - try await ensureConnection() - return await StoreKit.Product.SubscriptionInfo.isEligibleForIntroOffer(for: normalizedGroupID) - } - - /// Sync the user's in-app purchases with the App Store - /// - SeeAlso: https://developer.apple.com/documentation/storekit/appstore/sync() - /// - /// See: https://openiap.dev/docs/apis/ios/sync-ios public func syncIOS() async throws -> Bool { - try await ensureConnection() - do { - try await AppStore.sync() - return true - } catch { - throw PurchaseError.wrap(error, fallback: .serviceError) - } + try await provider.syncIOS() } - /// Open the offer/promo code redemption flow (the App Store offer code - /// redemption sheet on Apple platforms). - /// - Note: Builds made with Xcode 27+ return the verified transaction on - /// iOS 27+, Mac Catalyst 27+, and visionOS 27+. Earlier iOS and visionOS - /// paths return nil after presenting the system sheet. Mac Catalyst 16–26 - /// throws StoreKitError.unknown, and Catalyst 15 returns nil after a - /// StoreKit 1 call that has no effect. - /// - SeeAlso: https://developer.apple.com/documentation/storekit/appstore/presentoffercoderedeemsheet(from:options:) - /// - /// See: https://openiap.dev/docs/apis/open-redeem-offer-code public func openRedeemOfferCode() async throws -> PurchaseIOS? { - try await ensureConnection() - - #if compiler(>=6.4) - #if os(iOS) || os(visionOS) - if #available(iOS 27.0, macCatalyst 27.0, visionOS 27.0, *) { - guard let viewController = await activeViewController() else { - throw makePurchaseError( - code: .purchaseError, - message: "Cannot find an active view controller for offer-code redemption" - ) - } - - do { - let result = try await AppStore.presentOfferCodeRedeemSheet( - from: viewController, - options: [] - ) - let transaction = try checkVerified(result) - return await StoreKitTypesBridge.purchaseIOS( - from: transaction, - jwsRepresentation: result.jwsRepresentation - ) - } catch { - throw PurchaseError.wrap(error, fallback: .purchaseError) - } - } - #endif - #endif - - // StoreKit 2's scene-based sheet is available before the result-returning - // Apple 27 API, but cannot return the redeemed transaction directly. - #if os(iOS) || os(visionOS) - if #available(iOS 16.0, macCatalyst 16.0, visionOS 1.0, *) { - guard let scene = await activeWindowScene() else { - throw makePurchaseError( - code: .purchaseError, - message: "Cannot find an active window scene for offer-code redemption" - ) - } - do { - try await AppStore.presentOfferCodeRedeemSheet(in: scene) - return nil - } catch { - throw PurchaseError.wrap(error, fallback: .purchaseError) - } - } - #endif - - // iOS 15 requires the original StoreKit sheet. - #if os(iOS) - await MainActor.run { - SKPaymentQueue.default().presentCodeRedemptionSheet() - } - return nil - #else - throw makePurchaseError(code: .featureNotSupported) - #endif // os(iOS) + try await provider.openRedeemOfferCode() } - /// Deprecated. Use openRedeemOfferCode instead. - /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios @available(*, deprecated, message: "Use openRedeemOfferCode. Scheduled for removal in client protocol 1.0.0.") public func presentCodeRedemptionSheetIOS() async throws -> PurchaseIOS? { - try await openRedeemOfferCode() + try await provider.openRedeemOfferCode() } - /// Present the manage-subscriptions sheet. - /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios public func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] { - #if os(macOS) - // deepLinkToSubscriptions can open a browser on macOS, but that call - // returns immediately and cannot observe changes made outside the app. - throw makePurchaseError(code: .featureNotSupported) - #else - let previousTransactions = try await getAllTransactionsIOS() - try await deepLinkToSubscriptions(nil) - let currentTransactions = try await getAllTransactionsIOS() - return Self.changedPurchasesIOS(currentTransactions, comparedTo: previousTransactions) - #endif + try await provider.showManageSubscriptionsIOS() } - static func changedPurchasesIOS( - _ current: [PurchaseIOS], - comparedTo previous: [PurchaseIOS] - ) -> [PurchaseIOS] { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - var previousById: [String: Data] = [:] - for purchase in previous { - if let data = try? encoder.encode(purchase) { - previousById[purchase.id] = data - } - } - - return current.filter { purchase in - guard let currentData = try? encoder.encode(purchase) else { return true } - return previousById[purchase.id] != currentData - } + public func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { + try await provider.deepLinkToSubscriptions(options) } - // MARK: - External Purchase (iOS 17.4+, macOS 14.4+, tvOS 17.4+, visionOS 1.1+) - - /// Check eligibility for the external purchase notice sheet (iOS 17.4+). - /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios public func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { - try await ensureConnection() - // iOS 17.4+, macOS 14.4+, tvOS 17.4+, watchOS 10.4+, visionOS 1.1+: ExternalPurchase.canPresent - // Reference: https://developer.apple.com/documentation/storekit/externalpurchase/canpresent - if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, visionOS 1.1, *) { - return await ExternalPurchase.canPresent - } else { - return false - } + try await provider.canPresentExternalPurchaseNoticeIOS() } - /// Present the external purchase notice sheet (iOS 17.4+). - /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios public func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { - try await ensureConnection() - // iOS 17.4+, macOS 14.4+, tvOS 17.4+, watchOS 10.4+, visionOS 1.1+: ExternalPurchase.presentNoticeSheet - // Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, visionOS 1.1, *) { - guard await ExternalPurchase.canPresent else { - throw makePurchaseError( - code: .featureNotSupported, - message: "External purchase notice sheet is not available" - ) - } - - do { - let result = try await ExternalPurchase.presentNoticeSheet() - switch result { - case .continuedWithExternalPurchaseToken(let token): - // Return the token for reporting to Apple's External Purchase Server API - // The token is a String type in StoreKit - return ExternalPurchaseNoticeResultIOS( - error: nil, - externalPurchaseToken: token, - result: .continue - ) - case .cancelled: - // User dismissed the notice sheet - return ExternalPurchaseNoticeResultIOS( - error: nil, - externalPurchaseToken: nil, - result: .dismissed - ) - @unknown default: - // Handle future cases gracefully - throw makePurchaseError( - code: .unknown, - message: "Unexpected result from external purchase notice sheet" - ) - } - } catch let error as PurchaseError { - return ExternalPurchaseNoticeResultIOS( - error: error.message, - externalPurchaseToken: nil, - result: .dismissed - ) - } catch { - let purchaseError = makePurchaseError( - code: .serviceError, - message: "Failed to present external purchase notice: \(error.localizedDescription)" - ) - return ExternalPurchaseNoticeResultIOS( - error: purchaseError.message, - externalPurchaseToken: nil, - result: .dismissed - ) - } - } else { - throw makePurchaseError( - code: .featureNotSupported, - message: "External purchase notice sheet requires iOS 17.4+, macOS 14.4+, tvOS 17.4+, watchOS 10.4+, or visionOS 1.1+" - ) - } + try await provider.presentExternalPurchaseNoticeSheetIOS() } - /// Present an external purchase link, StoreKit External (iOS 16+). - /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios public func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { - try await ensureConnection() - // UIApplication.open is available on iOS/tvOS/visionOS but not watchOS/macOS - // Reference: https://developer.apple.com/documentation/uikit/uiapplication/1648685-open - #if os(iOS) || os(tvOS) || os(visionOS) - guard let customLink = URL(string: url) else { - return ExternalPurchaseLinkResultIOS( - error: "Invalid URL", - success: false - ) - } - - return await MainActor.run { - if UIApplication.shared.canOpenURL(customLink) { - UIApplication.shared.open(customLink, options: [:]) { success in - // Completion handler - link opened - } - return ExternalPurchaseLinkResultIOS(error: nil, success: true) - } else { - return ExternalPurchaseLinkResultIOS( - error: "Cannot open URL", - success: false - ) - } - } - #else - throw makePurchaseError(code: .featureNotSupported) - #endif // os(iOS) || os(tvOS) || os(visionOS) + try await provider.presentExternalPurchaseLinkIOS(url) } - // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) - - /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). - /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios public func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool { - try await ensureConnection() - // iOS 18.1+: ExternalPurchaseCustomLink.isEligible - // Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - if #available(iOS 18.1, macOS 15.1, tvOS 18.1, watchOS 11.1, visionOS 2.1, *) { - return await ExternalPurchaseCustomLink.isEligible - } else { - return false - } + try await provider.isEligibleForExternalPurchaseCustomLinkIOS() } - /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). - /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios - public func getExternalPurchaseCustomLinkTokenIOS( - _ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS - ) async throws -> ExternalPurchaseCustomLinkTokenResultIOS { - try await ensureConnection() - // iOS 18.1+: ExternalPurchaseCustomLink.token(for:) - // Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - if #available(iOS 18.1, macOS 15.1, tvOS 18.1, watchOS 11.1, visionOS 2.1, *) { - guard await ExternalPurchaseCustomLink.isEligible else { - return ExternalPurchaseCustomLinkTokenResultIOS( - error: "App is not eligible for ExternalPurchaseCustomLink", - token: nil - ) - } - - do { - // Token type is a String parameter, use enum's rawValue directly - guard let token = try await ExternalPurchaseCustomLink.token(for: tokenType.rawValue) else { - return ExternalPurchaseCustomLinkTokenResultIOS( - error: "Failed to retrieve external purchase token", - token: nil - ) - } - return ExternalPurchaseCustomLinkTokenResultIOS( - error: nil, - token: token.value - ) - } catch { - return ExternalPurchaseCustomLinkTokenResultIOS( - error: "Failed to get external purchase token: \(error.localizedDescription)", - token: nil - ) - } - } else { - throw makePurchaseError( - code: .featureNotSupported, - message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.1+, tvOS 18.1+, watchOS 11.1+, or visionOS 2.1+" - ) - } + public func getExternalPurchaseCustomLinkTokenIOS(_ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) async throws -> ExternalPurchaseCustomLinkTokenResultIOS { + try await provider.getExternalPurchaseCustomLinkTokenIOS(tokenType) } - /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). - /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios - public func showExternalPurchaseCustomLinkNoticeIOS( - _ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS - ) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS { - try await ensureConnection() - // iOS 18.1+: ExternalPurchaseCustomLink.showNotice(type:) - // Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - if #available(iOS 18.1, macOS 15.1, tvOS 18.1, watchOS 11.1, visionOS 2.1, *) { - guard await ExternalPurchaseCustomLink.isEligible else { - return ExternalPurchaseCustomLinkNoticeResultIOS( - continued: false, - error: "App is not eligible for ExternalPurchaseCustomLink" - ) - } - - do { - let storeKitNoticeType: ExternalPurchaseCustomLink.NoticeType = switch noticeType { - case .browser: - .browser - } - - let result = try await ExternalPurchaseCustomLink.showNotice(type: storeKitNoticeType) - switch result { - case .continued: - return ExternalPurchaseCustomLinkNoticeResultIOS( - continued: true, - error: nil - ) - case .cancelled: - return ExternalPurchaseCustomLinkNoticeResultIOS( - continued: false, - error: nil - ) - @unknown default: - return ExternalPurchaseCustomLinkNoticeResultIOS( - continued: false, - error: "Unknown notice result" - ) - } - } catch { - return ExternalPurchaseCustomLinkNoticeResultIOS( - continued: false, - error: "Failed to show notice: \(error.localizedDescription)" - ) - } - } else { - throw makePurchaseError( - code: .featureNotSupported, - message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.1+, tvOS 18.1+, watchOS 11.1+, or visionOS 2.1+" - ) - } + public func showExternalPurchaseCustomLinkNoticeIOS(_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS { + try await provider.showExternalPurchaseCustomLinkNoticeIOS(noticeType) } - // MARK: - Event Listener Registration - - public func purchaseUpdatedListener( - _ listener: @escaping PurchaseUpdatedListener, - options: PurchaseUpdatedListenerOptions? = nil - ) -> Subscription { - let subscription = Subscription(eventType: .purchaseUpdated) - state.addPurchaseUpdatedListener( - id: subscription.id, - listener: listener, - options: options - ) - return subscription + public func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener, options: PurchaseUpdatedListenerOptions? = nil) -> Subscription { + guard case .success(let selected) = selection else { return Subscription(eventType: .purchaseUpdated) } + return selected.module.purchaseUpdatedListener(listener, options: options) } public func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription { - let subscription = Subscription(eventType: .purchaseError) - state.addPurchaseErrorListener((subscription.id, listener)) - return subscription + guard case .success(let selected) = selection else { return Subscription(eventType: .purchaseError) } + return selected.module.purchaseErrorListener(listener) } public func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription { - let subscription = Subscription(eventType: .promotedProductIos) - let pendingSku = state.addPromotedProductListener((subscription.id, listener)) - if let pendingSku { - Task { - await MainActor.run { - listener(pendingSku) - } - } - } - return subscription + guard case .success(let selected) = selection else { return Subscription(eventType: .promotedProductIos) } + return selected.module.promotedProductListenerIOS(listener) } public func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription { - let subscription = Subscription(eventType: .subscriptionBillingIssue) - withMessageListenerRegistrationLock { - state.addSubscriptionBillingIssueListener((subscription.id, listener)) - } - // Do not consume StoreKit's pending Message sequence before connection - // initialization. performInitConnection starts it when listeners exist; - // this task covers registration after an already-completed init. - Task { [weak self] in - guard let self, - await self.state.isInitialized, - let generation = self.connection.currentConnectedGeneration() else { return } - try? self.startMessageListenerIfRegistered(generation: generation) - } - return subscription + guard case .success(let selected) = selection else { return Subscription(eventType: .subscriptionBillingIssue) } + return selected.module.subscriptionBillingIssueListener(listener) } - public func removeListener(_ subscription: Subscription) { - withMessageListenerRegistrationLock { - state.removeListener(id: subscription.id, type: subscription.eventType) - if subscription.eventType == .subscriptionBillingIssue, - !state.hasSubscriptionBillingIssueListeners() { - connection.stopMessageListenerTask() - } - } - Task { await MainActor.run { subscription.onRemove?() } } + public func removeListener(_ subscription: Subscription) -> Void { + if case .success(let selected) = selection { selected.module.removeListener(subscription) } } - public func removeAllListeners() { - withMessageListenerRegistrationLock { - state.removeAllListeners() - connection.stopMessageListenerTask() - } - } - - // MARK: - Private Helpers - - private func performInitConnection(generation: UInt64) async throws -> Bool { - try Task.checkCancellation() - try connection.ensureCurrent(generation) - - if await state.isInitialized { - try Task.checkCancellation() - try connection.ensureCurrent(generation) - return true - } - - _ = try connection.getOrCreateProductManager(generation: generation) - - registerPromotedPurchaseObserverIfNeeded() - - try Task.checkCancellation() - try connection.ensureCurrent(generation) - if await state.isInitialized { - try Task.checkCancellation() - try connection.ensureCurrent(generation) - return true - } - - guard AppStore.canMakePayments else { - emitPurchaseError(makePurchaseError(code: .iapNotAvailable)) - await state.setInitialized(false) - return false - } - - try Task.checkCancellation() - try connection.ensureCurrent(generation) - - await state.setInitialized(true) - try startTransactionListener(generation: generation) - try startUnfinishedTransactionProcessing(generation: generation) - try Task.checkCancellation() - try connection.ensureCurrent(generation) - try startMessageListenerIfRegistered(generation: generation) - try Task.checkCancellation() - try connection.ensureCurrent(generation) - return true - } - - private func cancelConnectionTasksForDeinit() { - let resources = connection.detachTasksForDeinit() - resources.initTask?.cancel() - resources.endTask?.cancel() - resources.updateListenerTask?.cancel() - resources.messageListenerTask?.cancel() - resources.unfinishedTransactionTask?.cancel() - } - - private func startPromotedPurchaseIntentListenerIfAvailableIOS() { - #if os(iOS) - if #available(iOS 16.4, macCatalyst 16.4, *) { - guard promotedPurchaseIntentTask == nil else { return } - promotedPurchaseIntentTask = Task { [weak self] in - for await intent in PurchaseIntent.intents { - guard !Task.isCancelled, let self else { return } - let offer: StoreKit.Product.SubscriptionOffer? - if #available(iOS 18.0, macCatalyst 18.0, *) { - offer = intent.offer - } else { - offer = nil - } - await self.promotedPurchaseIntentOffers.record( - offer, - for: intent.product.id - ) - if let productManager = self.connection.currentProductManager() { - await productManager.addProduct(intent.product) - } - self.emitPromotedProduct(intent.product.id) - } - } - } - #endif - } - - private func registerPromotedPurchaseObserverIfNeeded() { - #if os(iOS) - if #available(iOS 16.4, macCatalyst 16.4, *) { return } - - promotedPurchaseObserverLock.lock() - let shouldRegister = !didRegisterPromotedPurchaseObserver && - !isPromotedPurchaseObserverTransitionInFlight - if shouldRegister { - isPromotedPurchaseObserverTransitionInFlight = true - } - promotedPurchaseObserverLock.unlock() - - guard shouldRegister else { return } - - let addObserver = { - SKPaymentQueue.default().add(self) - } - - if Thread.isMainThread { - addObserver() - } else { - DispatchQueue.main.sync(execute: addObserver) - } - - promotedPurchaseObserverLock.lock() - didRegisterPromotedPurchaseObserver = true - isPromotedPurchaseObserverTransitionInFlight = false - promotedPurchaseObserverLock.unlock() - #endif // os(iOS) - } - - private func unregisterPromotedPurchaseObserverIfNeeded() { - #if os(iOS) - if #available(iOS 16.4, macCatalyst 16.4, *) { return } - - promotedPurchaseObserverLock.lock() - let shouldUnregister = didRegisterPromotedPurchaseObserver && - !isPromotedPurchaseObserverTransitionInFlight - if shouldUnregister { - isPromotedPurchaseObserverTransitionInFlight = true - } - promotedPurchaseObserverLock.unlock() - - guard shouldUnregister else { return } - - let removeObserver = { - SKPaymentQueue.default().remove(self) - } - - if Thread.isMainThread { - removeObserver() - } else { - DispatchQueue.main.sync(execute: removeObserver) - } - - promotedPurchaseObserverLock.lock() - didRegisterPromotedPurchaseObserver = false - isPromotedPurchaseObserverTransitionInFlight = false - promotedPurchaseObserverLock.unlock() - #endif // os(iOS) - } - - private func ensureConnection() async throws { - guard AppStore.canMakePayments else { - let error = makePurchaseError(code: .iapNotAvailable) - emitPurchaseError(error) - throw error - } - - while true { - if await hasInitializedConnection() { return } - if let endTask = connection.currentEndTask() { - await endTask.value - continue - } - - let initialized = try await initConnection() - if initialized, await hasInitializedConnection() { return } - if connection.currentEndTask() != nil { continue } - - let error = makePurchaseError(code: .initConnection) - emitPurchaseError(error) - throw error - } - } - - private func hasInitializedConnection() async -> Bool { - guard let generation = connection.currentConnectedGeneration(), - await state.isInitialized else { return false } - return connection.isConnected(generation: generation) - } - - private func cleanupExistingState() async { - let resources = connection.detachResourcesForCleanup() - resources.updateListenerTask?.cancel() - resources.messageListenerTask?.cancel() - resources.unfinishedTransactionTask?.cancel() - if let updateListenerTask = resources.updateListenerTask { - _ = try? await updateListenerTask.value - } - if let messageListenerTask = resources.messageListenerTask { - await messageListenerTask.value - } - if let unfinishedTransactionTask = resources.unfinishedTransactionTask { - await unfinishedTransactionTask.value - } - await state.reset() - if let manager = resources.productManager { await manager.removeAll() } - } - - private func storeProduct(for sku: String) async throws -> StoreKit.Product { - guard let productManager = connection.currentProductManager() else { - let error = makePurchaseError(code: .notPrepared) - emitPurchaseError(error) - throw error - } - - if let product = await productManager.getProduct(productID: sku) { - return product - } - - let products = try await StoreKit.Product.products(for: [sku]) - guard let first = products.first else { - let error = makePurchaseError(code: .skuNotFound, productId: sku) - emitPurchaseError(error) - throw error - } - await productManager.addProduct(first) - return first - } - - /// Resolves iOS purchase props from request params. - /// Returns either RequestPurchaseIosProps or RequestSubscriptionIosProps based on request type. - func resolveIOSPurchaseProps(from params: RequestPurchaseProps) throws -> any IosPropsProtocol { - try Self.validateIOSPurchaseProps(params) - switch params.request { - case let .purchase(platforms): - if let ios = platforms.apple { - return ios - } - case let .subscription(platforms): - if let ios = platforms.apple { - return ios - } - } - throw makePurchaseError(code: .purchaseError, message: "Missing iOS purchase parameters") + public func removeAllListeners() -> Void { + if case .success(let selected) = selection { selected.module.removeAllListeners() } } static func validateIOSPurchaseProps(_ params: RequestPurchaseProps) throws { - switch params.request { - case .purchase where params.type == .inApp: - return - case .subscription where params.type == .subs: - return - case .purchase: - throw PurchaseError.make( - code: .developerError, - message: "type must be in-app when requestPurchase is provided" - ) - case .subscription: - throw PurchaseError.make( - code: .developerError, - message: "type must be subs when requestSubscription is provided" - ) - } - } - - private func purchaseProductId(from params: RequestPurchaseProps) -> String? { - switch params.request { - case let .purchase(platforms): - return platforms.apple?.sku - case let .subscription(platforms): - return platforms.apple?.sku - } - } - - private func startTransactionListener(generation: UInt64) throws { - try connection.startTransactionListenerTask(generation: generation) { - OpenIapLog.debug("🎧 [TransactionListener] Starting Transaction.updates listener...") - return Task { [weak self] in - guard let self else { - OpenIapLog.debug("⚠️ [TransactionListener] Self is nil, exiting listener") - return - } - OpenIapLog.debug("✅ [TransactionListener] Listener task started, waiting for transactions...") - for await verification in Transaction.updates { - guard !Task.isCancelled, - self.connection.isCurrentGeneration(generation) else { return } - do { - guard await self.state.isInitialized else { continue } - guard self.connection.isCurrentGeneration(generation) else { return } - let transaction = try self.checkVerified(verification) - let transactionId = String(transaction.id) - - // Log all transaction details for debugging - OpenIapLog.debug(""" - 📦 Transaction received: - - ID: \(transactionId) - - Product: \(transaction.productID) - - purchaseDate: \(transaction.purchaseDate) - - subscriptionGroupID: \(transaction.subscriptionGroupID ?? "nil") - - revocationDate: \(transaction.revocationDate?.description ?? "nil") - """) - - if StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( - transaction.productType - ), - self.isInactiveSubscriptionTransaction(transaction) { - await transaction.finish() - guard self.connection.isCurrentGeneration(generation) else { return } - await self.state.removePending(id: transactionId) - OpenIapLog.debug(""" - 🧹 [TransactionListener] Finished inactive subscription update without emitting: - - SKU: \(transaction.productID) - - Transaction ID: \(transaction.id) - - Expiration: \(transaction.expirationDate?.description ?? "none") - - Revoked: \(transaction.revocationDate?.description ?? "none") - - Upgraded: \(transaction.isUpgraded) - """) - continue - } - - if transaction.revocationDate != nil { - OpenIapLog.debug("⏭️ Skipping revoked transaction: \(transactionId)") - continue - } - - let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) - guard self.connection.isCurrentGeneration(generation) else { return } - - // Default listeners receive each transaction id once per connection - // session. Non-deduping listeners can opt into StoreKit replays. - guard await self.state.recordPurchaseUpdateEmission( - id: transactionId, - pendingTransaction: transaction - ) else { - guard self.connection.isCurrentGeneration(generation) else { return } - self.emitPurchaseUpdate( - purchase, - isDuplicate: true, - duplicateSource: "Transaction.updates", - duplicateTransactionId: transactionId - ) - continue - } - guard self.connection.isCurrentGeneration(generation) else { return } - OpenIapLog.debug("✅ [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") - self.emitPurchaseUpdate(purchase) - } catch { - guard self.connection.isCurrentGeneration(generation) else { return } - let purchaseError: PurchaseError - if let existing = error as? PurchaseError { - purchaseError = existing - } else { - purchaseError = makePurchaseError(code: .transactionValidationFailed, message: error.localizedDescription) - } - self.emitPurchaseError(purchaseError) - } - } - } - } - } - - private func startUnfinishedTransactionProcessing(generation: UInt64) throws { - try connection.startUnfinishedTransactionTask(generation: generation) { - Task { [weak self] in - guard let self else { return } - defer { self.connection.clearUnfinishedTransactionTask(generation: generation) } - await self.processUnfinishedTransactions(generation: generation) - } - } - } - - private func processUnfinishedTransactions(generation: UInt64) async { - for await verification in Transaction.unfinished { - guard !Task.isCancelled, - connection.isCurrentGeneration(generation) else { return } - guard await state.isInitialized else { return } - guard connection.isCurrentGeneration(generation) else { return } - do { - let transaction = try checkVerified(verification) - if StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( - transaction.productType - ), - isInactiveSubscriptionTransaction(transaction) { - await transaction.finish() - guard connection.isCurrentGeneration(generation) else { return } - await state.removePending(id: String(transaction.id)) - OpenIapLog.debug(""" - 🧹 [processUnfinishedTransactions] Finished inactive subscription transaction: - - SKU: \(transaction.productID) - - Transaction ID: \(transaction.id) - - Expiration: \(transaction.expirationDate?.description ?? "none") - - Revoked: \(transaction.revocationDate?.description ?? "none") - - Upgraded: \(transaction.isUpgraded) - """) - continue - } - guard connection.isCurrentGeneration(generation) else { return } - await state.storePending(id: String(transaction.id), transaction: transaction) - } catch { - continue - } - } - } - - private func finishInactiveUnfinishedSubscriptions(productId: String) async { - for await verification in Transaction.unfinished { - if Task.isCancelled { return } - - do { - let transaction = try checkVerified(verification) - guard StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( - transaction.productType - ), - transaction.productID == productId, - isInactiveSubscriptionTransaction(transaction) - else { - continue - } - - await transaction.finish() - await state.removePending(id: String(transaction.id)) - OpenIapLog.debug(""" - 🧹 [requestPurchase] Cleared inactive unfinished subscription before purchase: - - SKU: \(transaction.productID) - - Transaction ID: \(transaction.id) - - Expiration: \(transaction.expirationDate?.description ?? "none") - - Revoked: \(transaction.revocationDate?.description ?? "none") - - Upgraded: \(transaction.isUpgraded) - """) - } catch { - OpenIapLog.debug("⚠️ Failed to clear inactive unfinished subscription: \(error.localizedDescription)") - continue - } - } - } - - private func preflightInactiveUnfinishedSubscriptions(productId: String) async { - let outcome = await withTaskGroup( - of: SubscriptionPreflightOutcome.self, - returning: SubscriptionPreflightOutcome.self - ) { group in - group.addTask { [weak self] in - guard let self else { return .completed } - await self.finishInactiveUnfinishedSubscriptions(productId: productId) - return .completed - } - - group.addTask { - do { - try await Task.sleep(nanoseconds: Self.subscriptionPreflightTimeoutNanoseconds) - return .timedOut - } catch { - return .completed - } - } - - let outcome = await group.next() ?? .completed - group.cancelAll() - return outcome - } - - if case .timedOut = outcome { - OpenIapLog.debug("⚠️ [requestPurchase] Inactive subscription cleanup timed out; continuing purchase flow") - } - } - - private func isInactiveSubscriptionTransaction(_ transaction: StoreKit.Transaction) -> Bool { - if transaction.revocationDate != nil || transaction.isUpgraded { - return true - } - - if let expirationDate = transaction.expirationDate { - return expirationDate <= Date() - } - - return false - } - - func checkVerified(_ result: VerificationResult) throws -> T { - switch result { - case .verified(let value): - return value - case .unverified: - throw makePurchaseError(code: .transactionValidationFailed, message: "Transaction verification failed") - } - } - - private func logDuplicatePurchaseUpdate( - source: String, - transactionId: String, - productId: String, - listenerCount: Int - ) { - let action = listenerCount > 0 - ? "Delivered duplicate purchase-updated event to \(listenerCount) non-deduping listener(s)." - : "Suppressed duplicate purchase-updated listener emission." - let message = """ - [PurchaseUpdateDedup] \(action) - - Source: \(source) - - Product: \(productId) - - Transaction ID: \(transactionId) - - Reason: this transaction id was already emitted during the current connection session. - - Scope: listeners dedupe by transaction id by default; listeners registered with dedupeTransactionIOS: false receive StoreKit replays. - """ - if listenerCount > 0 { - OpenIapLog.info(message) - } else { - OpenIapLog.debug(message) - } - } - - private func emitPurchaseUpdate( - _ purchase: Purchase, - isDuplicate: Bool = false, - duplicateSource: String? = nil, - duplicateTransactionId: String? = nil - ) { - Task { [state] in - let listeners = state.snapshotPurchaseUpdated(isDuplicate: isDuplicate) - if isDuplicate { - self.logDuplicatePurchaseUpdate( - source: duplicateSource ?? "unknown", - transactionId: duplicateTransactionId ?? purchase.id, - productId: purchase.productId, - listenerCount: listeners.count - ) - if listeners.isEmpty { - return - } - } - OpenIapLog.debug("✅ Emitting purchase update: Product=\(purchase.productId), Listeners=\(listeners.count)") - await MainActor.run { - listeners.forEach { $0(purchase) } - } - } - } - - private func emitPurchaseError(_ error: PurchaseError) { - guard !Self.suppressPurchaseErrorEmission else { return } - Task { [state] in - let listeners = state.snapshotPurchaseError() - await MainActor.run { - listeners.forEach { $0(error) } - } - } - } - - private func emitPromotedProduct(_ sku: String) { - Task { [state] in - let listeners = await state.recordPromotedProductAndSnapshotListeners(sku) - await MainActor.run { - listeners.forEach { $0(sku) } - } - } - } - - private func emitSubscriptionBillingIssue(_ purchase: Purchase) { - let listeners = state.snapshotSubscriptionBillingIssue() - Task { - await MainActor.run { - listeners.forEach { $0(purchase) } - } - } - } - - /// Starts the StoreKit 2 Message listener for subscription-billing-issue events. - /// - /// The `.billingIssue` reason (what we care about) ships on iOS 16.4+, Mac Catalyst - /// 16.4+, and visionOS 1.0+, so this method starts the `Message.messages` loop when - /// that availability holds. On macOS, tvOS, and watchOS the Message API is unavailable, - /// making this a silent no-op on those platforms. - /// - /// References: - /// - https://developer.apple.com/documentation/storekit/message - /// - https://developer.apple.com/documentation/storekit/message/reason-swift.struct/billingissue - private func startMessageListenerIfRegistered(generation: UInt64) throws { - try withMessageListenerRegistrationLock { - guard state.hasSubscriptionBillingIssueListeners() else { return } - try startMessageListener(generation: generation) - } - } - - private func startMessageListener(generation: UInt64) throws { - #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) - if #available(iOS 16.4, macCatalyst 16.4, visionOS 1.0, *) { - try connection.startMessageListenerTask(generation: generation) { - OpenIapLog.debug("🔔 [MessageListener] Starting Message.messages listener") - return Task { [weak self] in - guard let self else { return } - for await message in StoreKit.Message.messages { - guard !Task.isCancelled, - self.connection.isConnected(generation: generation), - await self.state.isInitialized else { return } - OpenIapLog.debug("🔔 [MessageListener] Received message: reason=\(message.reason)") - - // Listening to Message.messages transfers presentation control to - // the app. Preserve StoreKit's default UX for every reason (including - // price-increase consent and win-back offers) instead of silently - // suppressing messages that OpenIAP does not expose as events. - guard await self.displayStoreKitMessage(message), - !Task.isCancelled, - self.connection.isConnected(generation: generation), - await self.state.isInitialized else { return } - - guard case .billingIssue = message.reason else { - OpenIapLog.debug("🔔 [MessageListener] Skipping non-billingIssue message") - continue - } - OpenIapLog.debug("🔔 [MessageListener] billingIssue received — dispatching") - await self.dispatchBillingIssueMessage(generation: generation) - } - } - } - } else { - OpenIapLog.debug("🔔 [MessageListener] Skipped — Message.billingIssue unavailable") - } - #else - OpenIapLog.debug("🔔 [MessageListener] Skipped — unsupported platform") - #endif - } - - #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) - @available(iOS 16.0, macCatalyst 16.0, visionOS 1.0, *) - @MainActor - private func displayStoreKitMessage(_ message: StoreKit.Message) async -> Bool { - var retryDelay: UInt64 = 500_000_000 - while !Task.isCancelled { - if let scene = activeWindowScene(), scene.activationState == .foregroundActive { - do { - try message.display(in: scene) - return true - } catch { - OpenIapLog.warn("🔔 [MessageListener] StoreKit message display deferred: \(error.localizedDescription)") - } - } - - do { - try await Task.sleep(nanoseconds: retryDelay) - } catch { - return false - } - retryDelay = min(retryDelay * 2, 2_000_000_000) - } - return false - } - #endif - - /// Resolves the affected subscription group(s) from transaction history and emits the event. - /// - /// `StoreKit.Message` doesn't carry a transaction reference. Billing-retry subscriptions - /// are not necessarily current entitlements, so use `Transaction.all` only to discover the - /// user's subscription-group IDs, then treat `Product.SubscriptionInfo.status(for:)` as the - /// authoritative source. The status array is unordered across group members, so every - /// retry/grace entry and its own verified transaction must be inspected. - /// - /// Reference: https://developer.apple.com/documentation/storekit/product/subscriptioninfo/status(for:) - @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) - private func dispatchBillingIssueMessage(generation: UInt64) async { - guard !Task.isCancelled else { return } - var subscriptionGroupIds = Set() - for await verification in Transaction.all { - guard !Task.isCancelled else { return } - guard case .verified(let transaction) = verification, - StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( - transaction.productType - ), - let groupId = transaction.subscriptionGroupID else { continue } - subscriptionGroupIds.insert(groupId) - } - guard !subscriptionGroupIds.isEmpty else { - OpenIapLog.debug("🔔 [MessageListener] billingIssue received but no auto-renewable transaction history is present") - return - } - - var emitted = false - var emittedTransactionIds = Set() - for groupId in subscriptionGroupIds { - guard !Task.isCancelled else { return } - let statusArray: [StoreKit.Product.SubscriptionInfo.Status] - do { - statusArray = try await StoreKit.Product.SubscriptionInfo.status(for: groupId) - } catch { - OpenIapLog.debug("🔔 [MessageListener] Subscription status failed for group \(groupId): \(error.localizedDescription)") - continue - } - for status in statusArray { - guard !Task.isCancelled else { return } - guard status.state == .inBillingRetryPeriod || status.state == .inGracePeriod, - case .verified(let transaction) = status.transaction, - emittedTransactionIds.insert(transaction.id).inserted else { continue } - let purchase = await StoreKitTypesBridge.purchase( - from: transaction, - jwsRepresentation: status.transaction.jwsRepresentation - ) - guard !Task.isCancelled, - connection.isConnected(generation: generation), - await state.isInitialized else { return } - emitSubscriptionBillingIssue(purchase) - emitted = true - } - } - if !emitted { - OpenIapLog.debug("🔔 [MessageListener] billingIssue received but no subscription currently reports retry/grace state") - } - } - - private func makePurchaseError( - code: ErrorCode, - productId: String? = nil, - message: String? = nil, - debugMessage: String? = nil - ) -> PurchaseError { - PurchaseError( - code: code, - debugMessage: debugMessage, - message: message ?? defaultMessage(for: code), - productId: productId - ) - } - - private func defaultMessage(for code: ErrorCode) -> String { - switch code { - case .unknown: return "Unknown error occurred" - case .userCancelled: return "User cancelled the purchase flow" - case .userError: return "User action error" - case .itemUnavailable: return "Item unavailable" - case .remoteError: return "Remote service error" - case .networkError: return "Network connection error" - case .serviceError: return "Store service error" - case .purchaseVerificationFailed: return "Purchase verification failed" - case .purchaseVerificationFinished: return "Transaction already finished" - case .purchaseVerificationFinishFailed: return "Transaction finish failed" - case .notPrepared: return "Billing is not prepared" - case .notEnded: return "Billing connection not ended" - case .alreadyOwned: return "Item already owned" - case .developerError: return "Developer configuration error" - case .billingResponseJsonParseError: return "Failed to parse billing response" - case .deferredPayment: return "Payment was deferred (pending approval)" - case .interrupted: return "Purchase flow interrupted" - case .iapNotAvailable: return "In-app purchases not available on this device" - case .purchaseError: return "Purchase error" - case .syncError: return "Sync error" - case .transactionValidationFailed: return "Transaction validation failed" - case .activityUnavailable: return "Required activity is unavailable" - case .alreadyPrepared: return "Billing already prepared" - case .pending: return "Transaction pending" - case .connectionClosed: return "Connection closed" - case .initConnection: return "Failed to initialize billing connection" - case .serviceDisconnected: return "Billing service disconnected" - case .serviceTimeout: return "Billing service request timed out" - case .queryProduct: return "Failed to query product" - case .skuNotFound: return "SKU not found" - case .skuOfferMismatch: return "SKU offer mismatch" - case .itemNotOwned: return "Item not owned" - case .billingUnavailable: return "Billing unavailable" - case .featureNotSupported: return "Feature not supported on this platform" - case .emptySkuList: return "Empty SKU list provided" - case .duplicatePurchase: return "Duplicate purchase update detected" - } - } - - private func withMessageListenerRegistrationLock(_ body: () throws -> T) rethrows -> T { - messageListenerRegistrationLock.lock() - defer { messageListenerRegistrationLock.unlock() } - return try body() - } - - #if os(iOS) || os(tvOS) || os(visionOS) - @available(iOS 17.0, tvOS 17.0, visionOS 1.0, *) - @MainActor - private func purchaseWithActiveScene( - product: StoreKit.Product, - options: Set, - sku: String - ) async throws -> StoreKit.Product.PurchaseResult { - guard let scene = activeWindowScene() else { - let error = makePurchaseError(code: .purchaseError, productId: sku, message: "Could not find active window scene") - emitPurchaseError(error) - throw error - } - - OpenIapLog.debug(""" - 🛒 [requestPurchase] Presenting StoreKit purchase sheet: - - SKU: \(sku) - - Scene state: \(scene.activationState.rawValue) - """) - return try await product.purchase(confirmIn: scene, options: options) - } - - @MainActor - private func activeWindowScene() -> UIWindowScene? { - let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } - return scenes.first { $0.activationState == .foregroundActive } - ?? scenes.first { $0.activationState == .foregroundInactive } - ?? scenes.first - } - - @MainActor - private func activeViewController() -> UIViewController? { - guard let scene = activeWindowScene() else { - return nil - } - let window = scene.windows.first(where: \.isKeyWindow) ?? scene.windows.first - return topViewController(from: window?.rootViewController) - } - - @MainActor - private func topViewController(from root: UIViewController?) -> UIViewController? { - if let presented = root?.presentedViewController { - return topViewController(from: presented) - } - if let navigation = root as? UINavigationController { - return topViewController(from: navigation.visibleViewController) - } - if let tab = root as? UITabBarController { - return topViewController(from: tab.selectedViewController) - } - return root - } - #endif - - @available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *) - private func mapAppTransaction(_ transaction: StoreKit.AppTransaction) -> AppTransaction { - let appVersionId = transaction.appVersionID.map(Double.init) ?? 0 - let appVersion = transaction.appVersion - let appId = transaction.appID.map(Double.init) ?? 0 - - // iOS 18.4+ properties - only compile with Xcode 16.4+ (Swift 6.1 compiler+) - // This prevents build failures on Xcode 16.3 and below - var appTransactionId: String? = nil - var originalPlatformValue: String? = nil - var revocationDateValue: Double? = nil - var storeTypeValue: String? = nil - - // Swift 6.1 compiler+ (Xcode 16.4+): appTransactionID is back-deployed - // to the AppTransaction baseline; originalPlatform itself starts at 18.4. - #if compiler(>=6.1) - appTransactionId = transaction.appTransactionID - if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { - originalPlatformValue = transaction.originalPlatform.rawValue - } else { - originalPlatformValue = transaction.originalPlatformStringRepresentation - } - #endif // compiler(>=6.1) - - #if compiler(>=6.4) - // Xcode 27 first publishes this property, but StoreKit declares it - // back-deployed to the AppTransaction baseline runtimes. - revocationDateValue = transaction.revocationDate?.milliseconds - - if #available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *) { - storeTypeValue = transaction.storeType.rawValue - } - #endif - - return AppTransaction( - appId: appId, - appTransactionId: appTransactionId, - appVersion: appVersion, - appVersionId: appVersionId, - bundleId: transaction.bundleID, - deviceVerification: transaction.deviceVerification.base64EncodedString(), - deviceVerificationNonce: transaction.deviceVerificationNonce.uuidString, - environment: transaction.environment.rawValue, - originalAppVersion: transaction.originalAppVersion, - originalPlatform: originalPlatformValue, - originalPurchaseDate: transaction.originalPurchaseDate.milliseconds, - preorderDate: transaction.preorderDate?.milliseconds, - revocationDate: revocationDateValue, - signedDate: transaction.signedDate.milliseconds, - storeType: storeTypeValue - ) + try OpenIapStoreKitModule.validateIOSPurchaseProps(params) } } -// iOS-only: SKPaymentTransactionObserver extension for promoted in-app purchases -// Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases #if os(iOS) extension OpenIapModule: SKPaymentTransactionObserver { public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - // StoreKit 2 handles transactions via Transaction.updates; nothing to do here. + guard let storeKit = (try? provider) as? OpenIapStoreKitModule else { return } + storeKit.paymentQueue(queue, updatedTransactions: transactions) } public func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - Task { [weak self] in - guard let self else { return } - self.emitPromotedProduct(product.productIdentifier) - } - return false + guard let storeKit = (try? provider) as? OpenIapStoreKitModule else { return false } + return storeKit.paymentQueue(queue, shouldAddStorePayment: payment, for: product) } } -#endif // os(iOS) +#endif diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift index a9c9c0bb3..ea8c40d61 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -115,7 +115,6 @@ public protocol OpenIapModuleProtocol { } public extension OpenIapModuleProtocol { - /// Default implementation that throws. Override in your module to provide actual verification. func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { throw PurchaseError(code: .featureNotSupported, message: "verifyPurchaseWithProvider not supported") } @@ -165,4 +164,82 @@ public extension OpenIapModuleProtocol { message: "showExternalPurchaseCustomLinkNoticeIOS not supported" ) } + + func getPromotedProductIOS() async throws -> ProductIOS? { + throw PurchaseError.make(code: .featureNotSupported, message: "getPromotedProductIOS not supported by this provider") + } + + func getAllTransactionsIOS() async throws -> [PurchaseIOS] { + throw PurchaseError.make(code: .featureNotSupported, message: "getAllTransactionsIOS not supported by this provider") + } + + func getPendingTransactionsIOS() async throws -> [PurchaseIOS] { + throw PurchaseError.make(code: .featureNotSupported, message: "getPendingTransactionsIOS not supported by this provider") + } + + func clearTransactionIOS() async throws -> Bool { + throw PurchaseError.make(code: .featureNotSupported, message: "clearTransactionIOS not supported by this provider") + } + + func isTransactionVerifiedIOS(sku: String) async throws -> Bool { + throw PurchaseError.make(code: .featureNotSupported, message: "isTransactionVerifiedIOS not supported by this provider") + } + + func getTransactionJwsIOS(sku: String) async throws -> String? { + throw PurchaseError.make(code: .featureNotSupported, message: "getTransactionJwsIOS not supported by this provider") + } + + func currentEntitlementIOS(sku: String) async throws -> PurchaseIOS? { + throw PurchaseError.make(code: .featureNotSupported, message: "currentEntitlementIOS not supported by this provider") + } + + func latestTransactionIOS(sku: String) async throws -> PurchaseIOS? { + throw PurchaseError.make(code: .featureNotSupported, message: "latestTransactionIOS not supported by this provider") + } + + func getReceiptDataIOS() async throws -> String? { + throw PurchaseError.make(code: .featureNotSupported, message: "getReceiptDataIOS not supported by this provider") + } + + @available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *) + func getAppTransactionIOS() async throws -> AppTransaction? { + throw PurchaseError.make(code: .featureNotSupported, message: "getAppTransactionIOS not supported by this provider") + } + + func subscriptionStatusIOS(sku: String) async throws -> [SubscriptionStatusIOS] { + throw PurchaseError.make(code: .featureNotSupported, message: "subscriptionStatusIOS not supported by this provider") + } + + func isEligibleForIntroOfferIOS(groupID: String) async throws -> Bool { + throw PurchaseError.make(code: .featureNotSupported, message: "isEligibleForIntroOfferIOS not supported by this provider") + } + + func beginRefundRequestIOS(sku: String) async throws -> String? { + throw PurchaseError.make(code: .featureNotSupported, message: "beginRefundRequestIOS not supported by this provider") + } + + func syncIOS() async throws -> Bool { + throw PurchaseError.make(code: .featureNotSupported, message: "syncIOS not supported by this provider") + } + + func openRedeemOfferCode() async throws -> PurchaseIOS? { + throw PurchaseError.make(code: .featureNotSupported, message: "openRedeemOfferCode not supported by this provider") + } + + @available(*, deprecated, message: "Use openRedeemOfferCode. Scheduled for removal in client protocol 1.0.0.") + func presentCodeRedemptionSheetIOS() async throws -> PurchaseIOS? { + throw PurchaseError.make(code: .featureNotSupported, message: "presentCodeRedemptionSheetIOS not supported by this provider") + } + + func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] { + throw PurchaseError.make(code: .featureNotSupported, message: "showManageSubscriptionsIOS not supported by this provider") + } + + func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription { + Subscription(eventType: .promotedProductIos) + } + + func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription { + Subscription(eventType: .subscriptionBillingIssue) + } } diff --git a/packages/apple/Sources/OpenIapProvider.swift b/packages/apple/Sources/OpenIapProvider.swift new file mode 100644 index 000000000..086aeab3b --- /dev/null +++ b/packages/apple/Sources/OpenIapProvider.swift @@ -0,0 +1,151 @@ +import Foundation + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +public protocol OpenIapProviderFactory: AnyObject { + init() + var storeId: String { get } + var coreVersion: String { get } + var clientProtocolVersion: String { get } + var capabilities: Set { get } + func create() throws -> any OpenIapModuleProtocol +} + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +public extension OpenIapProviderFactory { + var capabilities: Set { [] } + var descriptor: StoreProviderDescriptor { + StoreProviderDescriptor( + capabilities: capabilities.sorted(), clientProtocolVersion: clientProtocolVersion, + coreVersion: coreVersion, platform: .ios, storeId: storeId + ) + } +} + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +@objc(OpenIapAppleProviderFactory) +public final class OpenIapAppleProviderFactory: NSObject, OpenIapProviderFactory { + public required override init() { super.init() } + public var storeId: String { StoreIds.Apple } + public var coreVersion: String { OpenIapVersion.current } + public var clientProtocolVersion: String { OpenIapVersion.clientProtocolVersion } + public var capabilities: Set { + var supported: Set = ["pendingPurchases"] + #if targetEnvironment(macCatalyst) + #if compiler(>=6.4) + if #available(macCatalyst 27.0, *) { supported.insert("offerCodeRedemption") } + #endif + #elseif os(iOS) || os(visionOS) + supported.insert("offerCodeRedemption") + #endif + #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) + if #available(iOS 16.4, macCatalyst 16.4, visionOS 1.0, *) { + supported.insert("subscriptionBillingIssue") + } + #endif + return supported + } + public func create() throws -> any OpenIapModuleProtocol { OpenIapStoreKitModule() } +} + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +struct OpenIapProviderSelection { + let storeId: String + let capabilities: Set + let module: any OpenIapModuleProtocol +} + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +public enum OpenIapProvider { + public static let metadataKey = "dev.hyo.openiap.PROVIDER" + public static var coreVersion: String { OpenIapVersion.current } + + public static func factory(bundle: Bundle = .main) throws -> any OpenIapProviderFactory { + guard let value = bundle.object(forInfoDictionaryKey: metadataKey) else { + return OpenIapAppleProviderFactory() + } + guard let className = value as? String, !className.isEmpty, + let type = NSClassFromString(className) as? (NSObject & OpenIapProviderFactory).Type else { + throw configurationError("Info.plist \(metadataKey) must name a linked NSObject implementing OpenIapProviderFactory with a public no-argument initializer.") + } + let factory = type.init() + try validate(factory.descriptor) + return factory + } + + public static func validate( + storeId: String, providerCoreVersion: String, runtimeCoreVersion: String = coreVersion + ) throws { + try validateVersion( + storeId: storeId, providerVersion: providerCoreVersion, + runtimeVersion: runtimeCoreVersion, clientProtocol: false + ) + } + + // One version check serves both contracts; the flag only selects the wording. + private static func validateVersion( + storeId: String, providerVersion: String, runtimeVersion: String, clientProtocol: Bool + ) throws { + guard storeId.range(of: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", options: .regularExpression) + == storeId.startIndex.. [String] { + guard value.range(of: pattern, options: .regularExpression) == value.startIndex.. linked : needed.count > linked.count + break + } + let prerelease = providerVersion.split(separator: "+")[0].contains("-") + || runtimeVersion.split(separator: "+")[0].contains("-") + guard required[0] == available[0], !newer, + !prerelease || providerVersion == runtimeVersion else { + if clientProtocol { + throw configurationError("Provider '\(storeId)' requires Client Protocol \(providerVersion); this app links \(runtimeVersion). Update the provider or the OpenIAP runtime to a matching protocol.") + } + throw configurationError("Provider '\(storeId)' requires OpenIAP \(providerVersion); this app links \(runtimeVersion). Use a compatible provider or core version.") + } + } + + public static func validate(_ descriptor: StoreProviderDescriptor) throws { + guard descriptor.platform == .ios else { throw configurationError("Apple provider must use the ios platform binding.") } + try validate(storeId: descriptor.storeId, providerCoreVersion: descriptor.coreVersion) + try validateVersion(storeId: descriptor.storeId, providerVersion: descriptor.clientProtocolVersion, + runtimeVersion: OpenIapVersion.clientProtocolVersion, clientProtocol: true) + if descriptor.clientProtocolVersion.hasPrefix("0."), + descriptor.clientProtocolVersion.split(separator: ".")[1] + != OpenIapVersion.clientProtocolVersion.split(separator: ".")[1] { + throw configurationError("Provider '\(descriptor.storeId)' must implement Client Protocol \(OpenIapVersion.clientProtocolVersion).") + } + } + + static func select(bundle: Bundle) -> Result { + do { return select(factory: try factory(bundle: bundle)) } + catch { return .failure(configurationError(error.localizedDescription)) } + } + + static func select(factory: any OpenIapProviderFactory) -> Result { + do { + try validate(factory.descriptor) + return .success(OpenIapProviderSelection( + storeId: factory.storeId, capabilities: factory.capabilities, module: try factory.create() + )) + } catch let error as PurchaseError { return .failure(error) } + catch { return .failure(configurationError("Apple store provider could not load: \(error.localizedDescription)")) } + } + + private static func configurationError(_ message: String) -> PurchaseError { + .make(code: .developerError, message: message) + } +} diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index 6fcd2a0e2..7d8791b00 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -34,6 +34,10 @@ public final class OpenIapStore: ObservableObject { private let module: OpenIapModuleProtocol private var listenerTokens: [Subscription] = [] + private var listenerGeneration: UInt64 = 0 + private var purchaseRefreshTask: Task? + private var endConnectionTask: (id: UUID, task: Task)? + private var connectionTask: (id: UUID, task: Task)? // MARK: - Callbacks @@ -75,6 +79,7 @@ public final class OpenIapStore: ObservableObject { } deinit { + purchaseRefreshTask?.cancel() for token in listenerTokens { module.removeListener(token) } } @@ -82,31 +87,49 @@ public final class OpenIapStore: ObservableObject { private func setupListeners() { guard listenerTokens.isEmpty else { return } + let generation = listenerGeneration let purchaseUpdate = module.purchaseUpdatedListener({ [weak self] purchase in - Task { @MainActor in self?.handlePurchaseUpdate(purchase) } + Task { @MainActor in + guard let self, self.listenerGeneration == generation else { return } + self.handlePurchaseUpdate(purchase) + } }, options: nil) listenerTokens.append(purchaseUpdate) let purchaseError = module.purchaseErrorListener { [weak self] error in - Task { @MainActor in self?.handlePurchaseError(error) } + Task { @MainActor in + guard let self, self.listenerGeneration == generation else { return } + self.handlePurchaseError(error) + } } listenerTokens.append(purchaseError) let billingIssue = module.subscriptionBillingIssueListener { [weak self] purchase in - Task { @MainActor in self?.handleSubscriptionBillingIssue(purchase) } + Task { @MainActor in + guard let self, self.listenerGeneration == generation else { return } + self.handleSubscriptionBillingIssue(purchase) + } } listenerTokens.append(billingIssue) #if os(iOS) let promoted = module.promotedProductListenerIOS { [weak self] productId in - Task { @MainActor in self?.handlePromotedProduct(productId) } + Task { @MainActor in + guard let self, self.listenerGeneration == generation else { return } + self.handlePromotedProduct(productId) + } } listenerTokens.append(promoted) #endif } private func clearListeners() { + listenerGeneration &+= 1 + purchaseRefreshTask?.cancel() + purchaseRefreshTask = nil + status.loadings.initConnection = false + status.loadings.restorePurchases = false for token in listenerTokens { module.removeListener(token) } listenerTokens.removeAll() } @@ -114,16 +137,57 @@ public final class OpenIapStore: ObservableObject { // MARK: - Connection Management public func initConnection() async throws { - status.loadings.initConnection = true - defer { status.loadings.initConnection = false } - isConnected = try await module.initConnection() - setupListeners() + try Task.checkCancellation() + let task = enqueueConnectionOperation { [self] in + setupListeners() + status.loadings.initConnection = true + defer { status.loadings.initConnection = false } + isConnected = try await module.initConnection() + } + try await withTaskCancellationHandler { + try await task.value + try Task.checkCancellation() + } onCancel: { + task.cancel() + } } public func endConnection() async throws { - clearListeners() - _ = try await module.endConnection() - isConnected = false + if let endConnectionTask, endConnectionTask.id == connectionTask?.id { + return try await endConnectionTask.task.value + } + purchaseRefreshTask?.cancel() + purchaseRefreshTask = nil + let id = UUID() + let task = enqueueConnectionOperation(id: id) { [self] in + defer { + if endConnectionTask?.id == id { endConnectionTask = nil } + } + guard try await module.endConnection() else { + throw PurchaseError(code: .notEnded, message: "Store teardown did not complete") + } + clearListeners() + isConnected = false + } + endConnectionTask = (id, task) + try await task.value + } + + private func enqueueConnectionOperation( + id: UUID = UUID(), + _ operation: @escaping @MainActor @Sendable () async throws -> Void + ) -> Task { + let previous = connectionTask?.task + let task = Task { @MainActor in + defer { + if connectionTask?.id == id { connectionTask = nil } + } + if let previous { _ = try? await previous.value } + try Task.checkCancellation() + try await operation() + } + connectionTask = (id, task) + return task } // MARK: - Event Handlers @@ -145,39 +209,54 @@ public final class OpenIapStore: ObservableObject { onPurchaseSuccess?(purchase) - if let ios = purchase.asIOS() { - let shouldRefresh = ios.expirationDateIOS != nil - || ios.isAutoRenewing - || (ios.subscriptionGroupIdIOS?.isEmpty == false) - if shouldRefresh { - Task { - await refreshPurchases() - } + guard let ios = purchase.asIOS() else { return } + let shouldRefresh = ios.expirationDateIOS != nil + || ios.isAutoRenewing + || (ios.subscriptionGroupIdIOS?.isEmpty == false) + guard shouldRefresh else { return } + + // Optimistic fast path; the provider refresh below replaces it with the authoritative list. + // Skip if this transaction is upgraded - it means it's been replaced by a new subscription + if let expirationDate = ios.expirationDateIOS, ios.isUpgradedIOS != true { + let isActive = Date(timeIntervalSince1970: expirationDate / 1000) > Date() + + let newSubscription = ActiveSubscription( + autoRenewingAndroid: nil, + currentPlanId: ios.productId, + daysUntilExpirationIOS: nil, + environmentIOS: ios.environmentIOS, + expirationDateIOS: expirationDate, + isActive: isActive, + productId: ios.productId, + purchaseToken: ios.purchaseToken, + renewalInfoIOS: ios.renewalInfoIOS, + transactionDate: ios.transactionDate, + transactionId: ios.transactionId + ) - // Update activeSubscriptions directly from purchase data (avoid calling getActiveSubscriptions) - // Skip if this transaction is upgraded - it means it's been replaced by a new subscription - if let expirationDate = ios.expirationDateIOS, ios.isUpgradedIOS != true { - let isActive = Date(timeIntervalSince1970: expirationDate / 1000) > Date() - - let newSubscription = ActiveSubscription( - autoRenewingAndroid: nil, - currentPlanId: ios.productId, - daysUntilExpirationIOS: nil, - environmentIOS: ios.environmentIOS, - expirationDateIOS: expirationDate, - isActive: isActive, - productId: ios.productId, // Keep current productId, not autoRenewPreference - purchaseToken: ios.purchaseToken, - renewalInfoIOS: ios.renewalInfoIOS, // Future changes reflected here - transactionDate: ios.transactionDate, - transactionId: ios.transactionId - ) - - // Remove duplicates by transactionId - activeSubscriptions = activeSubscriptions.filter { existing in - existing.transactionId != ios.transactionId - } + [newSubscription] - } + // Remove duplicates by transactionId + activeSubscriptions = activeSubscriptions.filter { existing in + existing.transactionId != ios.transactionId + } + [newSubscription] + } + + guard endConnectionTask == nil else { return } + purchaseRefreshTask?.cancel() + let generation = listenerGeneration + let module = module + // Known gap: a non-subscription purchase during a refresh does not restart it. + purchaseRefreshTask = Task { [weak self] in + guard !Task.isCancelled, self?.listenerGeneration == generation else { return } + do { + let purchases = try await module.getAvailablePurchases(nil) + guard !Task.isCancelled, self?.listenerGeneration == generation else { return } + self?.availablePurchases = self?.deduplicatePurchases(purchases) ?? [] + let subscriptions = try await module.getActiveSubscriptions(nil) + guard !Task.isCancelled, self?.listenerGeneration == generation else { return } + self?.activeSubscriptions = subscriptions + } catch { + guard !Task.isCancelled, self?.listenerGeneration == generation else { return } + OpenIapLog.error("Failed to refresh purchases: \(error)") } } } @@ -242,10 +321,14 @@ public final class OpenIapStore: ObservableObject { // MARK: - Purchase Management public func getAvailablePurchases(options: PurchaseOptions? = nil) async throws { + let generation = listenerGeneration status.loadings.restorePurchases = true - defer { status.loadings.restorePurchases = false } + defer { + if listenerGeneration == generation { status.loadings.restorePurchases = false } + } let purchases = try await module.getAvailablePurchases(options) + guard !Task.isCancelled, listenerGeneration == generation else { throw CancellationError() } availablePurchases = deduplicatePurchases(purchases) OpenIapLog.debug("🧾 availablePurchases: \(purchases.count) total → \(availablePurchases.count) active") @@ -395,10 +478,10 @@ public final class OpenIapStore: ObservableObject { } public func getActiveSubscriptions(subscriptionIds: [String]? = nil) async throws { + let generation = listenerGeneration let subs = try await module.getActiveSubscriptions(subscriptionIds) - await MainActor.run { - activeSubscriptions = subs - } + guard !Task.isCancelled, listenerGeneration == generation else { throw CancellationError() } + activeSubscriptions = subs OpenIapLog.debug("📊 activeSubscriptions: \(activeSubscriptions.count) subscriptions") // Show renewal info details @@ -598,7 +681,7 @@ public final class OpenIapStore: ObservableObject { var skippedInactive = 0 for purchase in purchases { - guard let iosPurchase = purchase.asIOS() else { + guard let iosPurchase = purchase.asIOS(), iosPurchase.store == .apple else { nonSubscriptionPurchases.append(purchase) continue } @@ -621,7 +704,7 @@ public final class OpenIapStore: ObservableObject { || iosPurchase.purchaseState == .purchased } - guard isActive else { + guard isActive, iosPurchase.purchaseState == .purchased else { skippedInactive += 1 continue } @@ -670,14 +753,6 @@ public final class OpenIapStore: ObservableObject { return false } - - private func refreshPurchases() async { - do { - try await getAvailablePurchases() - } catch { - OpenIapLog.error("Failed to refresh purchases: \(error)") - } - } } // MARK: - Internal Helpers diff --git a/packages/apple/Sources/OpenIapStoreKitModule.swift b/packages/apple/Sources/OpenIapStoreKitModule.swift new file mode 100644 index 000000000..e566469f0 --- /dev/null +++ b/packages/apple/Sources/OpenIapStoreKitModule.swift @@ -0,0 +1,2750 @@ +import Foundation +import CoreFoundation +import StoreKit + +private struct IndexedProductEntry: @unchecked Sendable { + let index: Int + let entry: OpenIAP.ProductOrSubscription +} + +struct IapkitAmazonVerificationPayload: Codable { + let store: IapStore + let expectedProductId: String? + let receiptId: String + let sandbox: Bool? + let userId: String? + let includeClientPayload: Bool? +} + +struct IapkitHorizonVerificationPayload: Codable { + let store: IapStore + let sku: String + let userId: String + let includeClientPayload: Bool? +} + +struct EntitlementSelectionKey: Comparable { + let purchaseDate: Date + let transactionId: UInt64 + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.purchaseDate != rhs.purchaseDate { + return lhs.purchaseDate < rhs.purchaseDate + } + return lhs.transactionId < rhs.transactionId + } +} +// UIKit: Required for UIApplication, UIWindowScene on iOS/tvOS/visionOS +#if canImport(UIKit) +import UIKit +#endif +// AppKit: Required for NSApplication, NSWindow on macOS +#if canImport(AppKit) +import AppKit +#endif + +/// - SeeAlso: https://developer.apple.com/documentation/storekit/in-app_purchase +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +final class OpenIapStoreKitModule: NSObject, OpenIapModuleProtocol { + + static func iapkitVerificationURL(baseUrl: String?) throws -> URL { + let defaultBaseUrl = "https://kit.openiap.dev" + let trimmedBaseUrl = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var normalizedBaseUrl = trimmedBaseUrl.isEmpty ? defaultBaseUrl : trimmedBaseUrl + + while normalizedBaseUrl.hasSuffix("/") { + normalizedBaseUrl.removeLast() + } + + guard let components = URLComponents(string: normalizedBaseUrl) else { + throw PurchaseError.make( + code: .developerError, + message: "IAPKit baseUrl must be a valid HTTP(S) origin" + ) + } + let hasValidPort = components.port.map { (1...65_535).contains($0) } ?? true + guard let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.path.isEmpty, + components.query == nil, + components.fragment == nil, + hasValidPort, + let url = URL(string: "\(normalizedBaseUrl)/v1/purchase/verify") else { + throw PurchaseError.make( + code: .developerError, + message: "IAPKit baseUrl must be a valid HTTP(S) origin" + ) + } + + return url + } + + /// Optional enrichment: returns nil for anything this build cannot read, + /// including a `format` IAPKit added later. Never fails the receipt. + static func iapkitClientPayload(from rawValue: Any?) -> IapkitProductClientPayload? { + guard let rawValue, !(rawValue is NSNull) else { return nil } + guard let payload = rawValue as? [String: Any], + let formatString = payload["format"] as? String, + let format = IapkitClientPayloadFormat(rawValue: formatString), + let body = payload["body"] as? String, + let version = payload["version"] as? NSNumber, + let updatedAt = payload["updatedAt"] as? NSNumber, + CFGetTypeID(version) != CFBooleanGetTypeID(), + CFGetTypeID(updatedAt) != CFBooleanGetTypeID(), + version.doubleValue.isFinite, + version.doubleValue > 0, + version.doubleValue.rounded(.towardZero) == version.doubleValue, + updatedAt.doubleValue.isFinite, + updatedAt.doubleValue >= 0 else { + OpenIapLog.warn("Ignoring an IAPKit client payload this build cannot read") + return nil + } + + return IapkitProductClientPayload( + body: body, + format: format, + updatedAt: updatedAt.doubleValue, + version: version.doubleValue + ) + } + + static func makeIapkitRequest( + url: URL, + apiKey: String?, + body: Data + ) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let trimmedApiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedApiKey, trimmedApiKey.isEmpty == false { + request.setValue("Bearer \(trimmedApiKey)", forHTTPHeaderField: "Authorization") + } + request.httpBody = body + return request + } + + static func iapkitBoolean(from rawValue: Any?) throws -> Bool { + guard let value = rawValue as? NSNumber, + CFGetTypeID(value) == CFBooleanGetTypeID() else { + throw PurchaseError.make( + code: .purchaseVerificationFailed, + message: "IAPKit returned malformed response" + ) + } + + return value.boolValue + } + + /// Forwarded opaquely: `environment` is String in the spec, and App Store + /// Server also names `Xcode` and `LocalTesting`. + static func iapkitEnvironment(from rawValue: Any?) -> String? { + guard let rawValue, !(rawValue is NSNull) else { return nil } + guard let environment = rawValue as? String, environment.isEmpty == false else { + OpenIapLog.warn("Ignoring an IAPKit environment this build cannot read") + return nil + } + + return environment + } + + static func iapkitAmazonPayload( + from amazon: RequestVerifyPurchaseWithIapkitAmazonProps, + includeClientPayload: Bool? + ) throws -> IapkitAmazonVerificationPayload { + let receiptId = amazon.receiptId.trimmingCharacters(in: .whitespacesAndNewlines) + guard receiptId.isEmpty == false else { + throw PurchaseError.make(code: .developerError, message: "Amazon receiptId is required") + } + let userId = amazon.userId?.trimmingCharacters(in: .whitespacesAndNewlines) + + return IapkitAmazonVerificationPayload( + store: .amazon, + expectedProductId: amazon.expectedProductId, + receiptId: receiptId, + sandbox: amazon.sandbox, + userId: userId?.isEmpty == true ? nil : userId, + includeClientPayload: includeClientPayload + ) + } + + static func iapkitHorizonPayload( + from horizon: RequestVerifyPurchaseWithIapkitHorizonProps, + includeClientPayload: Bool? + ) throws -> IapkitHorizonVerificationPayload { + let sku = horizon.sku.trimmingCharacters(in: .whitespacesAndNewlines) + let userId = horizon.userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard sku.isEmpty == false, userId.isEmpty == false else { + throw PurchaseError.make( + code: .developerError, + message: "Horizon sku and userId are required" + ) + } + return IapkitHorizonVerificationPayload( + store: .horizon, + sku: sku, + userId: userId, + includeClientPayload: includeClientPayload + ) + } + + private let state = IapState() + private let connection = OpenIapConnectionLifecycle() + private let messageListenerRegistrationLock = NSLock() + private static let initRetryDelayNanoseconds: UInt64 = 1_000_000 + private static let subscriptionPreflightTimeoutNanoseconds: UInt64 = 750_000_000 + @TaskLocal private static var suppressPurchaseErrorEmission = false + + #if os(iOS) + private let promotedPurchaseObserverLock = NSLock() + private var didRegisterPromotedPurchaseObserver = false + private var isPromotedPurchaseObserverTransitionInFlight = false + private var promotedPurchaseIntentTask: Task? + private let promotedPurchaseIntentOffers = + PromotedPurchaseIntentOfferStore() + #endif + + private enum SubscriptionPreflightOutcome { + case completed + case timedOut + } + + override init() { + super.init() + startPromotedPurchaseIntentListenerIfAvailableIOS() + registerPromotedPurchaseObserverIfNeeded() + } + + deinit { + #if os(iOS) + promotedPurchaseIntentTask?.cancel() + #endif + unregisterPromotedPurchaseObserverIfNeeded() + cancelConnectionTasksForDeinit() + } + + // MARK: - Connection Management + + /// Initialize the store connection. Must be called before any other IAP API. + /// + /// - Returns: `true` once StoreKit is connected. + /// - Throws: When StoreKit fails to initialize. + /// - Note: This wraps `OpenIapStoreKit2.initialize()`. Safe to call multiple times — the + /// second call is a no-op. + /// + /// See: https://openiap.dev/docs/apis/init-connection + public func initConnection() async throws -> Bool { + while true { + try Task.checkCancellation() + if let endTask = connection.currentEndTask() { + await endTask.value + continue + } + + let connected = await hasInitializedConnection() + try Task.checkCancellation() + if connected { + return true + } + + guard let initWork = connection.makeInitTask(operation: { [weak self] generation in + guard let self else { return false } + return try await self.performInitConnection(generation: generation) + }) else { + if let endTask = connection.currentEndTask() { + await endTask.value + } else { + try await Task.sleep(nanoseconds: Self.initRetryDelayNanoseconds) + } + continue + } + + do { + let value = try await initWork.task.value + connection.clearInitTask(generation: initWork.generation) + return value + } catch is CancellationError { + connection.clearInitTask(generation: initWork.generation) + return false + } catch { + connection.clearInitTask(generation: initWork.generation) + throw error + } + } + } + + /// Close the store connection and release resources. + /// See: https://openiap.dev/docs/apis/end-connection + public func endConnection() async throws -> Bool { + let task = connection.makeEndTask { [weak self] in + guard let self else { return } + await self.cleanupExistingState() + } + await task.value + return true + } + + // MARK: - Product Management + + /// Retrieve products or subscriptions from the App Store by SKU. + /// + /// - Parameter params: `ProductRequest` with `skus` (the product identifiers) and an + /// optional `type` (`.inApp`, `.subs`, or `.all`; defaults to `.inApp` when omitted — + /// matches the cross-platform Flutter / expo-iap / react-native-iap / godot SDKs). + /// - Returns: A `FetchProductsResult` variant — `Product[]` for `.inApp`, + /// `ProductSubscription[]` for `.subs`, or a mixed list for `.all`. + /// - Throws: When the store rejects the request (unknown SKU, network failure, not connected). + /// - Note: This is a regular promise-based call. Do not confuse with `request*` APIs, + /// which are event-based. + /// + /// See: https://openiap.dev/docs/apis/fetch-products + public func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult { + guard !params.skus.isEmpty else { + let error = makePurchaseError(code: .emptySkuList) + emitPurchaseError(error) + throw error + } + + try await ensureConnection() + guard let productManager = connection.currentProductManager() else { + let error = makePurchaseError(code: .notPrepared) + emitPurchaseError(error) + throw error + } + + let fetchedProducts: [StoreKit.Product] + do { + fetchedProducts = try await StoreKit.Product.products(for: params.skus) + for product in fetchedProducts { + await productManager.addProduct(product) + } + } catch is CancellationError { + throw CancellationError() + } catch { + let purchaseError = makePurchaseError( + code: .queryProduct, + message: error.localizedDescription, + debugMessage: error.localizedDescription + ) + emitPurchaseError(purchaseError) + throw purchaseError + } + + // Preserve the concrete product variant so subscription-only fields are + // not discarded when callers request a mixed result. + let allEntries = await withTaskGroup(of: IndexedProductEntry.self) { group in + for (index, product) in fetchedProducts.enumerated() { + group.addTask { + if let subscription = await StoreKitTypesBridge.productSubscription(from: product) { + return IndexedProductEntry( + index: index, + entry: .productSubscription(subscription) + ) + } + + let productEntry = await StoreKitTypesBridge.product(from: product) + return IndexedProductEntry(index: index, entry: .product(productEntry)) + } + } + + var entries = Array( + repeating: nil, + count: fetchedProducts.count + ) + for await result in group { + entries[result.index] = result.entry + } + return entries.compactMap { $0 } + } + + var productEntries: [OpenIAP.Product] = [] + var subscriptionEntries: [OpenIAP.ProductSubscription] = [] + for entry in allEntries { + switch entry { + case .product(let product): + productEntries.append(product) + case .productSubscription(let subscription): + subscriptionEntries.append(subscription) + } + } + + switch params.type ?? .inApp { + case .subs: + return .subscriptions(subscriptionEntries.isEmpty ? nil : subscriptionEntries) + case .inApp: + return .products(productEntries.isEmpty ? nil : productEntries) + case .all: + return .all(allEntries.isEmpty ? nil : allEntries) + } + } + + /// Read the App Store-promoted product, if any. + /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios + public func getPromotedProductIOS() async throws -> ProductIOS? { + // iOS-only: Promoted in-app purchases (App Store promotional purchases) only available on iOS + // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases + #if os(iOS) + let sku = await state.promotedProductIdentifier() + guard let sku else { return nil } + + do { + try await ensureConnection() + } catch let purchaseError as PurchaseError { + throw purchaseError + } + + await state.setPromotedProductId(sku) + + do { + let product = try await storeProduct(for: sku) + return await StoreKitTypesBridge.productIOS(from: product) + } catch is CancellationError { + throw CancellationError() + } catch let purchaseError as PurchaseError { + await state.setPromotedProductId(nil) + throw purchaseError + } catch { + let wrapped = makePurchaseError( + code: .queryProduct, + productId: sku, + message: error.localizedDescription, + debugMessage: error.localizedDescription + ) + emitPurchaseError(wrapped) + await state.setPromotedProductId(nil) + throw wrapped + } + #else + return nil + #endif // os(iOS) + } + + // MARK: - Purchase Management + + /// Initiate a purchase flow. The result is delivered via the purchase update listener, + /// NOT through the return value. + /// + /// - Parameter params: `RequestPurchaseProps` — discriminated by `type`: + /// `.inApp` for one-time products (use `request.apple.sku`), `.subs` for subscriptions. + /// - Returns: The dispatched request payload. Do not rely on this for the purchase outcome. + /// - Throws: Synchronous rejections from StoreKit (e.g. user cancel before sheet, not prepared). + /// - Warning: Event-based. Listen via `purchaseUpdatedListener` / `purchaseErrorListener`. + /// + /// See: https://openiap.dev/docs/apis/request-purchase + public func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { + let fallbackProductId = purchaseProductId(from: params) + do { + return try await Self.$suppressPurchaseErrorEmission.withValue(true) { + try await performRequestPurchase(params) + } + } catch is CancellationError { + throw CancellationError() + } catch let purchaseError as PurchaseError { + var canonicalError = purchaseError + if canonicalError.productId == nil { + canonicalError.productId = fallbackProductId + } + await deliverPurchaseError(canonicalError) + throw canonicalError + } catch { + let canonicalError = PurchaseError.wrap( + error, + fallback: .purchaseError, + productId: fallbackProductId + ) + await deliverPurchaseError(canonicalError) + throw canonicalError + } + } + + /// Performs the StoreKit request while helper-level error emissions are + /// suppressed. `requestPurchase` emits the final canonical error exactly once. + private func performRequestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { + let iosProps = try resolveIOSPurchaseProps(from: params) + try await ensureConnection() + let sku = iosProps.sku + let product = try await storeProduct(for: sku) + #if os(iOS) + let canUsePurchaseIntentOffer: Bool + if let subscriptionProps = iosProps as? RequestSubscriptionIosProps { + canUsePurchaseIntentOffer = subscriptionProps.winBackOffer == nil && + subscriptionProps.withOffer == nil && + subscriptionProps.promotionalOfferJWS == nil + } else { + canUsePurchaseIntentOffer = false + } + let purchaseIntentOfferLease = canUsePurchaseIntentOffer + ? await promotedPurchaseIntentOffers.lease(for: sku) + : nil + let purchaseIntentOffer = purchaseIntentOfferLease?.offer + #else + let purchaseIntentOffer: StoreKit.Product.SubscriptionOffer? = nil + #endif + let options: Set + do { + options = try StoreKitTypesBridge.purchaseOptionsIOS( + from: iosProps, + product: product, + purchaseIntentOffer: purchaseIntentOffer + ) + } catch { + #if os(iOS) + if let purchaseIntentOfferLease { + await promotedPurchaseIntentOffers.release( + purchaseIntentOfferLease, + for: sku + ) + } + #endif + throw error + } + + if StoreKitTypesBridge.isAutoRenewingSubscriptionProductType(product.type) { + await preflightInactiveUnfinishedSubscriptions(productId: sku) + } + + let result: StoreKit.Product.PurchaseResult + do { + // iOS 17.0+, tvOS 17.0+, macOS 15.2+: Use purchase(confirmIn:options:) for better purchase confirmation UI + // Reference: https://developer.apple.com/documentation/storekit/product/purchase(confirmin:options:)-6dj6y + #if os(iOS) || os(tvOS) || os(visionOS) + // iOS/tvOS/visionOS: Use UIWindowScene (not available on watchOS) + if #available(iOS 17.0, tvOS 17.0, visionOS 1.0, *) { + result = try await purchaseWithActiveScene(product: product, options: options, sku: sku) + } else { + result = try await product.purchase(options: options) + } + #elseif os(macOS) + // macOS: Use NSWindow (macOS 15.2+) + if #available(macOS 15.2, *) { + let window: NSWindow? = await MainActor.run { + NSApplication.shared.windows.first + } + guard let window else { + let error = makePurchaseError(code: .purchaseError, message: "Could not find window") + throw error + } + result = try await product.purchase(confirmIn: window, options: options) + } else { + result = try await product.purchase(options: options) + } + #else + result = try await product.purchase(options: options) + #endif + } catch { + #if os(iOS) + if let purchaseIntentOfferLease { + await promotedPurchaseIntentOffers.release( + purchaseIntentOfferLease, + for: sku + ) + } + #endif + // A cancelled purchase is not a purchase failure. + if error is CancellationError { + throw CancellationError() + } + // Enhanced error handling for promotional offers + if iosProps.withOffer != nil { + OpenIapLog.error("Purchase with promotional offer failed: \(error.localizedDescription)") + let enhancedMessage = """ + Promotional offer purchase failed: \(error.localizedDescription) + + Common causes: + 1. Invalid signature - verify server generates correct signature with exact parameter order + 2. Empty appAccountToken - ensure empty string ('') is used in signature, not null + 3. Sandbox testing - ensure current subscription has expired before testing offers + 4. Offer eligibility - user may not be eligible for this promotional offer + """ + let purchaseError = makePurchaseError( + code: .purchaseError, + productId: sku, + message: enhancedMessage, + debugMessage: error.localizedDescription + ) + throw purchaseError + } + + // Use PurchaseError.wrap to automatically map errors (including StoreKitError.userCancelled) + let purchaseError = PurchaseError.wrap(error, fallback: .purchaseError, productId: sku) + throw purchaseError + } + + // Keep the intent offer available across local validation and + // presentation-context failures. Consume only after StoreKit has + // accepted the matching purchase attempt and returned a result. + #if os(iOS) + if let purchaseIntentOfferLease { + await promotedPurchaseIntentOffers.consume( + purchaseIntentOfferLease, + for: sku + ) + } + #endif + + switch result { + case .success(let verification): + let transaction = try checkVerified(verification) + if StoreKitTypesBridge.isAutoRenewingSubscriptionProductType(product.type), + isInactiveSubscriptionTransaction(transaction) { + await transaction.finish() + await state.removePending(id: String(transaction.id)) + OpenIapLog.debug(""" + 🧹 [requestPurchase] Finished inactive subscription transaction before emitting: + - SKU: \(transaction.productID) + - Transaction ID: \(transaction.id) + - Expiration: \(transaction.expirationDate?.description ?? "none") + - Revoked: \(transaction.revocationDate?.description ?? "none") + - Upgraded: \(transaction.isUpgraded) + """) + let error = makePurchaseError( + code: .purchaseError, + productId: sku, + message: "Finished an inactive subscription transaction. Please retry the purchase." + ) + throw error + } + let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) + let transactionId = String(transaction.id) + let shouldAutoFinish = iosProps.andDangerouslyFinishTransactionAutomatically == true + + let isSubscription = StoreKitTypesBridge.isAutoRenewingSubscriptionProductType(product.type) + + OpenIapLog.debug(""" + 🎯 [requestPurchase] Purchase successful: + - Requested SKU: \(sku) + - Returned Product: \(transaction.productID) + - Transaction ID: \(transactionId) + - Purchase Date: \(transaction.purchaseDate) + - Product Type: \(isSubscription ? "subscription" : "non-subscription") + - SKU matches: \(transaction.productID == sku) + - Note: \(isSubscription ? "Subscription transactions will be emitted via Transaction.updates" : "Emitting directly") + """) + + let shouldEmit = await state.recordPurchaseUpdateEmission( + id: transactionId, + pendingTransaction: shouldAutoFinish ? nil : transaction + ) + // Dedupe only controls listener delivery; auto-finish must still + // complete the StoreKit transaction lifecycle for replayed updates. + if shouldAutoFinish { + await transaction.finish() + await state.removePending(id: transactionId) + } + + // StoreKit can replay unfinished transactions through multiple paths during a + // connection session. Default listeners receive each transaction id once; + // non-deduping listeners can opt into the replay for diagnostics. + emitPurchaseUpdate( + purchase, + isDuplicate: !shouldEmit, + duplicateSource: "requestPurchase", + duplicateTransactionId: transactionId + ) + + return .purchase(purchase) + + case .userCancelled: + let error = makePurchaseError(code: .userCancelled, productId: sku) + throw error + + case .pending: + let error = makePurchaseError(code: .deferredPayment, productId: sku) + throw error + + @unknown default: + let error = makePurchaseError(code: .unknown, productId: sku) + throw error + } + } + + /// Restore non-consumable and active subscription purchases. + /// See: https://openiap.dev/docs/apis/restore-purchases + public func restorePurchases() async throws -> Void { + _ = try await syncIOS() + } + + /// List the user's purchases held by StoreKit. By default reads `Transaction.all` (the + /// full history including refunded / revoked entries). Pass + /// `onlyIncludeActiveItemsIOS = true` to switch to `Transaction.currentEntitlements`, + /// which narrows the result to active non-consumables and live subscriptions. + /// + /// - Parameter options: Optional iOS-specific flags. `onlyIncludeActiveItemsIOS` + /// toggles between `Transaction.all` (default) and `Transaction.currentEntitlements`. + /// `alsoPublishToEventListenerIOS` re-emits each purchase on the update listener. + /// - Returns: An array of `Purchase` values matching the selected scope. + /// - Throws: When the StoreKit query fails. + /// + /// See: https://openiap.dev/docs/apis/get-available-purchases + public func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] { + try await ensureConnection() + let onlyActive = options?.onlyIncludeActiveItemsIOS ?? false + let shouldPublish = options?.alsoPublishToEventListenerIOS ?? false + var purchasedItems: [Purchase] = [] + + for await verification in (onlyActive ? Transaction.currentEntitlements : Transaction.all) { + let transaction = try checkVerified(verification) + + if onlyActive, let expirationDate = transaction.expirationDate, expirationDate <= Date() { + continue + } + + let purchase = await StoreKitTypesBridge.purchase( + from: transaction, + jwsRepresentation: verification.jwsRepresentation + ) + purchasedItems.append(purchase) + } + + // Publish only after the complete sequence verifies successfully so a + // failed query cannot leak a partial authoritative result via events. + if shouldPublish { + purchasedItems.forEach { emitPurchaseUpdate($0) } + } + + OpenIapLog.debug("🔍 getAvailablePurchases: \(purchasedItems.count) purchases (onlyActive=\(onlyActive))") + return purchasedItems + } + + /// Get the full StoreKit 2 transaction history as `PurchaseIOS` values. + /// Requires the SKIncludeConsumableInAppPurchaseHistory Info.plist key in the host app + /// for finished consumables to be included in `Transaction.all` (iOS 18+). + /// Unlike `getAvailablePurchases(_:)`, this method always reads from + /// `Transaction.all` and returns the iOS-specific `PurchaseIOS` shape rather than + /// the cross-platform `Purchase` type. + /// + /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios + public func getAllTransactionsIOS() async throws -> [PurchaseIOS] { + try await ensureConnection() + var transactions: [PurchaseIOS] = [] + + for await verification in Transaction.all { + let transaction = try checkVerified(verification) + let purchase = await StoreKitTypesBridge.purchaseIOS( + from: transaction, + jwsRepresentation: verification.jwsRepresentation + ) + transactions.append(purchase) + } + + OpenIapLog.debug("🔍 getAllTransactionsIOS: \(transactions.count) transactions") + return transactions + } + + // MARK: - Transaction Management + + /// Complete a purchase transaction. Call after server-side verification to remove it + /// from the StoreKit queue. + /// + /// - Parameters: + /// - purchase: The `PurchaseInput` to finalize. + /// - isConsumable: Pass `true` for consumables (re-buyable like coins), `false` for + /// subscriptions and non-consumables. Affects only Android consume vs acknowledge; + /// iOS always calls `Transaction.finish()`. + /// - Throws: When the platform finalization fails. + /// - Important: iOS unfinished transactions replay on every app launch. (Android purchases + /// must be acknowledged within 3 days, but that path lives in the Android module.) + /// + /// See: https://openiap.dev/docs/apis/finish-transaction + public func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws -> Void { + try await ensureConnection() + let identifier = purchase.id + + if let pending = await state.getPending(id: identifier) { + await pending.finish() + await state.removePending(id: identifier) + return + } + + guard let numericId = UInt64(identifier) else { + let error = makePurchaseError(code: .purchaseError, message: "Invalid transaction identifier") + emitPurchaseError(error) + throw error + } + + for await result in Transaction.unfinished { + do { + let transaction = try checkVerified(result) + if transaction.id == numericId { + await transaction.finish() + await state.removePending(id: identifier) + return + } + } catch { + OpenIapLog.debug("⚠️ finishTransaction unfinished lookup failed: \(error.localizedDescription)") + } + } + + if let result = await Transaction.latest(for: purchase.productId) { + do { + let transaction = try checkVerified(result) + if transaction.id == numericId { + await transaction.finish() + await state.removePending(id: identifier) + return + } + } catch { + OpenIapLog.debug("⚠️ finishTransaction latest lookup failed: \(error.localizedDescription)") + } + } + + OpenIapLog.debug("ℹ️ finishTransaction skipped: transaction \(identifier) is not pending/unfinished/latest; treating as already finished") + } + + /// List unfinished StoreKit transactions. + /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios + public func getPendingTransactionsIOS() async throws -> [PurchaseIOS] { + try await ensureConnection() + var purchases: [PurchaseIOS] = [] + for await verification in Transaction.unfinished { + let transaction = try checkVerified(verification) + await state.storePending(id: String(transaction.id), transaction: transaction) + purchases.append( + await StoreKitTypesBridge.purchaseIOS( + from: transaction, + jwsRepresentation: verification.jwsRepresentation + ) + ) + } + return purchases + } + + /// Clear pending transactions in the queue (sandbox helper). + /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios + public func clearTransactionIOS() async throws -> Bool { + try await ensureConnection() + for await result in Transaction.unfinished { + do { + let transaction = try checkVerified(result) + await transaction.finish() + await state.removePending(id: String(transaction.id)) + } catch { + continue + } + } + return true + } + + /// Check whether a transaction's JWS verification passed. + /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios + public func isTransactionVerifiedIOS(sku: String) async throws -> Bool { + try await ensureConnection() + let product = try await storeProduct(for: sku) + guard let result = await product.latestTransaction else { return false } + do { + _ = try checkVerified(result) + return true + } catch { + return false + } + } + + /// Return the JWS string for a transaction. + /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios + public func getTransactionJwsIOS(sku: String) async throws -> String? { + try await ensureConnection() + let product = try await storeProduct(for: sku) + guard let result = await product.latestTransaction else { + let error = makePurchaseError(code: .skuNotFound, productId: sku) + emitPurchaseError(error) + throw error + } + return result.jwsRepresentation + } + + // MARK: - Validation + + /// Get base64 receipt data (legacy validation). + /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios + public func getReceiptDataIOS() async throws -> String? { + guard let receiptURL = Bundle.main.appStoreReceiptURL, + FileManager.default.fileExists(atPath: receiptURL.path) else { + return nil + } + let data = try Data(contentsOf: receiptURL) + return data.base64EncodedString() + } + + private func performVerifyPurchaseIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS { + let receiptData = (try? await getReceiptDataIOS()) ?? "" + var latestPurchase: Purchase? = nil + var jws: String = "" + var isValid = false + + // Apple options with sku is required + guard let appleOptions = props.apple, !appleOptions.sku.isEmpty else { + throw makePurchaseError( + code: .developerError, + message: "Apple verification requires apple options with sku" + ) + } + + do { + let product = try await storeProduct(for: appleOptions.sku) + if let result = await product.latestTransaction { + jws = result.jwsRepresentation + let transaction = try checkVerified(result) + latestPurchase = .purchaseIos(await StoreKitTypesBridge.purchaseIOS(from: transaction, jwsRepresentation: result.jwsRepresentation)) + isValid = true + } + } catch { + isValid = false + } + + return VerifyPurchaseResultIOS( + isValid: isValid, + jwsRepresentation: jws, + latestTransaction: latestPurchase, + receiptData: receiptData + ) + } + + /// Verify a purchase against your own backend (returns isValid + raw store metadata). + /// See: https://openiap.dev/docs/features/validation#verify-purchase + public func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { + try await ensureConnection() + let iosResult = try await performVerifyPurchaseIOS(props) + return .verifyPurchaseResultIos(iosResult) + } + + /// Verify via a managed provider (currently IAPKit; the PurchaseVerificationProvider enum exposes only Iapkit today). + /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider + public func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { + struct IapkitApplePayload: Codable { + let store: IapStore + let jws: String + let includeClientPayload: Bool? + } + struct IapkitGooglePayload: Codable { + let store: IapStore + let purchaseToken: String + let includeClientPayload: Bool? + } + + func extractIapkitErrorMessage(from json: [String: Any]) -> String? { + func extractStringMessage(_ value: String) -> String { + if let data = value.data(using: .utf8), + let nested = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return extractIapkitErrorMessage(from: nested) ?? value + } + return value + } + + if let details = json["details"] as? [String: Any], + let originalError = details["originalError"] as? String { + return extractStringMessage(originalError) + } + + if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { + return extractIapkitErrorMessage(from: firstError) + } + + if let message = json["message"] as? String { + return extractStringMessage(message) + } + + if let error = json["error"] as? String { + return extractStringMessage(error) + } + + return nil + } + + func buildIapkitPayload(props: RequestVerifyPurchaseWithIapkitProps) throws -> (store: IapStore, body: Data) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + let payloadCount = [ + props.apple != nil, + props.google != nil, + props.horizon != nil, + props.amazon != nil, + ].filter { $0 }.count + guard payloadCount == 1 else { + throw makePurchaseError(code: .developerError, message: "IAPKit verification requires exactly one store payload") + } + + if let apple = props.apple { + let jws = apple.jws.trimmingCharacters(in: .whitespacesAndNewlines) + guard jws.isEmpty == false else { + throw makePurchaseError(code: .developerError, message: "JWS is required") + } + let payload = IapkitApplePayload( + store: .apple, + jws: jws, + includeClientPayload: props.includeClientPayload + ) + return (.apple, try encoder.encode(payload)) + } + + if let google = props.google { + let purchaseToken = google.purchaseToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard purchaseToken.isEmpty == false else { + throw makePurchaseError(code: .developerError, message: "Google purchase token is required") + } + let payload = IapkitGooglePayload( + store: .google, + purchaseToken: purchaseToken, + includeClientPayload: props.includeClientPayload + ) + return (.google, try encoder.encode(payload)) + } + + if let horizon = props.horizon { + let payload = try Self.iapkitHorizonPayload( + from: horizon, + includeClientPayload: props.includeClientPayload + ) + return (.horizon, try encoder.encode(payload)) + } + + if let amazon = props.amazon { + let payload = try Self.iapkitAmazonPayload( + from: amazon, + includeClientPayload: props.includeClientPayload + ) + return (.amazon, try encoder.encode(payload)) + } + + throw makePurchaseError(code: .developerError, message: "IAPKit verification payload is required") + } + + func verifyPurchaseWithIapkit(props: RequestVerifyPurchaseWithIapkitProps) async throws -> RequestVerifyPurchaseWithIapkitResult { + let url = try Self.iapkitVerificationURL(baseUrl: props.baseUrl) + + let payload = try buildIapkitPayload(props: props) + let store = payload.store + let body = payload.body + + let request = Self.makeIapkitRequest(url: url, apiKey: props.apiKey, body: body) + + OpenIapLog.debug("IAPKit request URL: \(url.absoluteString)") + OpenIapLog.debug("IAPKit request body bytes=\(body.count)") + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch is CancellationError { + throw CancellationError() + } catch { + if let urlError = error as? URLError, urlError.code == .cancelled { + throw CancellationError() + } + OpenIapLog.warn("IAPKit verification network error: \(error.localizedDescription)") + throw makePurchaseError(code: .networkError, message: error.localizedDescription) + } + guard let httpResponse = response as? HTTPURLResponse else { + throw makePurchaseError(code: .networkError, message: "Invalid response") + } + guard (200...299).contains(httpResponse.statusCode) else { + let responseBody = String(data: data, encoding: .utf8) ?? "" + OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP \(httpResponse.statusCode))") + var errorMessage = "HTTP \(httpResponse.statusCode)" + if let jsonData = responseBody.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + errorMessage = extractIapkitErrorMessage(from: json) ?? errorMessage + } + throw makePurchaseError(code: .purchaseVerificationFailed, message: errorMessage) + } + + OpenIapLog.debug("IAPKit verification response received: bytes=\(data.count)") + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + OpenIapLog.warn("Failed to parse IAPKit verification response") + throw makePurchaseError(code: .purchaseVerificationFailed, message: "Unable to parse verification response") + } + + if let errors = json["errors"] as? [[String: Any]], let firstError = errors.first { + let errorMessage = extractIapkitErrorMessage(from: firstError) ?? "Unknown error" + let errorCode = firstError["code"] as? String ?? "unknown" + OpenIapLog.warn("IAPKit verification error: \(errorCode) - \(errorMessage)") + throw makePurchaseError(code: .purchaseVerificationFailed, message: errorMessage) + } + + let isValid = try Self.iapkitBoolean(from: json["isValid"]) + guard let stateString = json["state"] as? String, + let storeString = json["store"] as? String, + let parsedStore = IapStore(rawValue: storeString), + parsedStore == store else { + OpenIapLog.warn("IAPKit verification response missing required fields") + throw makePurchaseError(code: .purchaseVerificationFailed, message: "IAPKit returned malformed response") + } + let normalizedState = stateString.lowercased().replacingOccurrences(of: "_", with: "-") + let parsedState = IapkitPurchaseState(rawValue: normalizedState) ?? .unknown + let productId: String? + if let rawProductId = json["productId"], !(rawProductId is NSNull) { + guard let value = rawProductId as? String else { + OpenIapLog.warn("IAPKit verification response contains a non-string productId") + throw makePurchaseError(code: .purchaseVerificationFailed, message: "IAPKit returned malformed response") + } + productId = value + } else { + productId = nil + } + let environment = Self.iapkitEnvironment(from: json["environment"]) + let clientPayload = Self.iapkitClientPayload(from: json["clientPayload"]) + OpenIapLog.info("IAPKit verification result: store=\(parsedStore.rawValue), isValid=\(isValid), state=\(parsedState.rawValue)") + return RequestVerifyPurchaseWithIapkitResult( + clientPayload: clientPayload, + environment: environment, + isValid: isValid, + productId: productId, + state: parsedState, + store: parsedStore, + storeId: parsedStore == .google ? "play" : parsedStore.rawValue + ) + } + try await ensureConnection() + guard props.provider == .iapkit else { + throw makePurchaseError(code: .featureNotSupported, message: "Provider \(props.provider.rawValue) is not supported") + } + guard let iapkit = props.iapkit else { + throw makePurchaseError(code: .developerError, message: "Missing IAPKit verification parameters") + } + let result = try await verifyPurchaseWithIapkit(props: iapkit) + return VerifyPurchaseWithProviderResult( + iapkit: result, + provider: props.provider + ) + } + + // MARK: - Store Information + + /// Return the user's storefront country code. + /// See: https://openiap.dev/docs/apis/get-storefront + public func getStorefront() async throws -> String { + try await ensureConnection() + guard let storefront = await Storefront.current else { + let error = makePurchaseError(code: .unknown) + emitPurchaseError(error) + throw error + } + return storefront.countryCode + } + + /// Get the app transaction that represents the user's purchase of the app + /// - Note: Available on iOS 16.0+, macOS 14.0+, tvOS 16.0+, watchOS 9.0+ + /// - SeeAlso: https://developer.apple.com/documentation/storekit/apptransaction + /// + /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios + @available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *) + public func getAppTransactionIOS() async throws -> AppTransaction? { + try await ensureConnection() + let verification = try await StoreKit.AppTransaction.shared + switch verification { + case .verified(let transaction): + return mapAppTransaction(transaction) + case .unverified: + return nil + } + } + + // MARK: - Subscription Management + + /// Get details of all currently active subscriptions. + /// See: https://openiap.dev/docs/apis/get-active-subscriptions + public func getActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] { + try await ensureConnection() + var allSubscriptions: [ActiveSubscription] = [] + for await verification in Transaction.currentEntitlements { + let transaction = try checkVerified(verification) + guard StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( + transaction.productType + ) else { + continue + } + + // Skip upgraded subscriptions - they've been replaced + if transaction.isUpgraded { + continue + } + + if let ids = subscriptionIds, ids.contains(transaction.productID) == false { + continue + } + let expiration = transaction.expirationDate + // If expiration date is nil, treat as inactive (expired or invalid) + // This prevents treating subscriptions without expiration dates as active + let isActive = expiration.map { $0 > Date() } ?? false + let dayDelta = expiration.map { Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 0 } + let daysUntilExpiration = dayDelta.map { Double($0) } + let environment: String? + // OpenIapStoreKitModule already requires tvOS 16, so only the lower + // iOS and watchOS deployment floors need a runtime check here. + if #available(iOS 16.0, watchOS 9.0, *) { + environment = transaction.environment.rawValue + } else { + environment = nil + } + + // Fetch renewal info for subscription + let renewalInfo = await StoreKitTypesBridge.subscriptionRenewalInfoIOS(for: transaction) + + allSubscriptions.append( + ActiveSubscription( + autoRenewingAndroid: nil, + currentPlanId: transaction.productID, + daysUntilExpirationIOS: daysUntilExpiration, + environmentIOS: environment, + expirationDateIOS: expiration?.milliseconds, + isActive: isActive, + productId: transaction.productID, + purchaseToken: verification.jwsRepresentation, + renewalInfoIOS: renewalInfo, + transactionDate: transaction.purchaseDate.milliseconds, + transactionId: String(transaction.id) + ) + ) + } + + OpenIapLog.debug("📊 Returning \(allSubscriptions.count) active subscriptions") + + // Upgraded subscriptions are already filtered out by transaction.isUpgraded check + // Return all remaining subscriptions (active, downgraded, and cancelled) + return allSubscriptions + } + + /// Check whether the user has any active subscription. + /// See: https://openiap.dev/docs/apis/has-active-subscriptions + public func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool { + let subscriptions = try await getActiveSubscriptions(subscriptionIds) + return subscriptions.contains { $0.isActive } + } + + /// Show the subscription management interface + /// - Note: Available on iOS 15.0+, iPadOS 15.0+, Mac Catalyst 15.0+, macOS 14.0+, visionOS 1.0+. Not available on tvOS (subscriptions are managed in Settings > Accounts) or watchOS. + /// - Note: macOS has no native StoreKit manage-subscriptions sheet, so this opens https://apps.apple.com/account/subscriptions in the default browser. + /// - SeeAlso: https://developer.apple.com/documentation/storekit/appstore/showmanagesubscriptions(in:) + /// + /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions + public func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { + try await ensureConnection() + // tvOS: AppStore.showManageSubscriptions not available on tvOS (subscriptions managed in Settings > Accounts) + // watchOS: No window scene UI for showManageSubscriptions + #if !os(tvOS) && !os(watchOS) + #if canImport(UIKit) + let scene: UIWindowScene? = await MainActor.run { + UIApplication.shared.connectedScenes.first as? UIWindowScene + } + guard let scene else { + throw makePurchaseError(code: .unknown) + } + try await AppStore.showManageSubscriptions(in: scene) + #elseif canImport(AppKit) + // macOS: AppStore.showManageSubscriptions requires a UIWindowScene, which + // does not exist on macOS. Fall back to opening the App Store + // subscriptions management page in the default browser. + guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { + throw makePurchaseError(code: .unknown, message: "Invalid subscriptions management URL") + } + let opened = await MainActor.run { + NSWorkspace.shared.open(url) + } + guard opened else { + throw makePurchaseError(code: .unknown, message: "Failed to open subscriptions management page") + } + #endif + #else + throw makePurchaseError(code: .featureNotSupported) + #endif // !os(tvOS) && !os(watchOS) + } + + /// Get subscription status objects from StoreKit 2. + /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios + public func subscriptionStatusIOS(sku: String) async throws -> [SubscriptionStatusIOS] { + try await ensureConnection() + let product = try await storeProduct(for: sku) + guard let subscription = product.subscription else { + let error = makePurchaseError(code: .skuNotFound, productId: sku) + emitPurchaseError(error) + throw error + } + + do { + let statuses = try await subscription.status + return statuses.map { status in + let renewalInfo: RenewalInfoIOS? + switch status.renewalInfo { + case .verified(let info): + let jsonString = String(data: info.jsonRepresentation, encoding: .utf8) ?? info.jsonRepresentation.base64EncodedString() + renewalInfo = RenewalInfoIOS( + autoRenewPreference: info.autoRenewPreference, + commitmentInfo: StoreKitTypesBridge.renewalCommitmentInfoIOS(from: info), + isInBillingRetry: info.isInBillingRetry, + jsonRepresentation: jsonString, + renewalBillingPlanType: StoreKitTypesBridge.renewalBillingPlanTypeIOS(from: info), + willAutoRenew: info.willAutoRenew + ) + case .unverified: + renewalInfo = nil + } + return SubscriptionStatusIOS( + renewalInfo: renewalInfo, + state: String(describing: status.state) + ) + } + } catch is CancellationError { + throw CancellationError() + } catch { + let purchaseError = makePurchaseError(code: .serviceError, message: error.localizedDescription) + emitPurchaseError(purchaseError) + throw purchaseError + } + } + + /// Get the user's current entitlement for a product. + /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios + public func currentEntitlementIOS(sku: String) async throws -> PurchaseIOS? { + try await ensureConnection() + let product = try await storeProduct(for: sku) + + let entitlements: Transaction.Transactions + // Product.currentEntitlements ships in the Xcode 26 SDK and is + // back-deployed by StoreKit. Xcode 16.4 also uses Swift 6.1, so the + // compiler guard must stay at Swift 6.2+ to keep that SDK buildable. + #if compiler(>=6.2) + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { + entitlements = product.currentEntitlements + } else { + entitlements = Transaction.currentEntitlements + } + #else + entitlements = Transaction.currentEntitlements + #endif + + var latest: ( + key: EntitlementSelectionKey, + transaction: StoreKit.Transaction, + jwsRepresentation: String + )? + for await result in entitlements { + guard result.unsafePayloadValue.productID == sku else { continue } + do { + let transaction = try checkVerified(result) + let key = EntitlementSelectionKey( + purchaseDate: transaction.purchaseDate, + transactionId: transaction.id + ) + if latest.map({ $0.key < key }) ?? true { + latest = (key, transaction, result.jwsRepresentation) + } + } catch { + let error = makePurchaseError( + code: .transactionValidationFailed, + message: error.localizedDescription + ) + emitPurchaseError(error) + throw error + } + } + guard let latest else { return nil } + return await StoreKitTypesBridge.purchaseIOS( + from: latest.transaction, + jwsRepresentation: latest.jwsRepresentation + ) + } + + /// Get the latest verified transaction for a product. + /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios + public func latestTransactionIOS(sku: String) async throws -> PurchaseIOS? { + try await ensureConnection() + let product = try await storeProduct(for: sku) + guard let result = await product.latestTransaction else { return nil } + do { + let transaction = try checkVerified(result) + return await StoreKitTypesBridge.purchaseIOS(from: transaction, jwsRepresentation: result.jwsRepresentation) + } catch { + let error = makePurchaseError(code: .transactionValidationFailed, message: error.localizedDescription) + emitPurchaseError(error) + throw error + } + } + + // MARK: - Refunds + + /// Begin a refund request for a transaction + /// - Note: Available on iOS 15.0+, iPadOS 15.0+, Mac Catalyst 15.0+, macOS 12.0+, visionOS 1.0+. Not available on tvOS or watchOS. + /// - Note: macOS has no native StoreKit refund sheet, so this opens https://reportaproblem.apple.com in the default browser and returns nil (the refund is resolved outside the app). + /// - SeeAlso: https://developer.apple.com/documentation/storekit/transaction/3803220-beginrefundrequest + /// + /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios + public func beginRefundRequestIOS(sku: String) async throws -> String? { + try await ensureConnection() + // tvOS: Transaction.beginRefundRequest not available on tvOS + // watchOS: Transaction.beginRefundRequest not available on watchOS + #if !os(tvOS) && !os(watchOS) + let product = try await storeProduct(for: sku) + guard let result = await product.latestTransaction else { + let error = makePurchaseError(code: .skuNotFound, productId: sku) + emitPurchaseError(error) + throw error + } + + let transaction = try checkVerified(result) + + #if canImport(UIKit) + let scene: UIWindowScene? = await MainActor.run { + UIApplication.shared.connectedScenes.first as? UIWindowScene + } + guard let scene else { + let error = makePurchaseError(code: .purchaseError, message: "Cannot find window scene") + emitPurchaseError(error) + throw error + } + let status = try await transaction.beginRefundRequest(in: scene) + switch status { + case .success: + return "success" + case .userCancelled: + return "userCancelled" + @unknown default: + return nil + } + #elseif canImport(AppKit) + // macOS: Transaction.beginRefundRequest requires a UIWindowScene, which does + // not exist on macOS. Fall back to opening Apple's refund request page in the + // default browser for the verified transaction. The refund is resolved outside + // the app, so return nil (no definitive status), matching @unknown default. + OpenIapLog.debug("Opening refund request page for transaction \(transaction.id)") + guard let url = URL(string: "https://reportaproblem.apple.com/") else { + let error = makePurchaseError(code: .purchaseError, message: "Invalid refund request URL") + emitPurchaseError(error) + throw error + } + let opened = await MainActor.run { + NSWorkspace.shared.open(url) + } + guard opened else { + let error = makePurchaseError(code: .purchaseError, message: "Failed to open refund request page") + emitPurchaseError(error) + throw error + } + return nil + #endif + #else + throw makePurchaseError(code: .featureNotSupported) + #endif // !os(tvOS) && !os(watchOS) + } + + // MARK: - Misc + + /// Check if the user is eligible for an introductory offer for a subscription group + /// - SeeAlso: https://developer.apple.com/documentation/storekit/product/subscriptioninfo/iseligibleforintrooffer(for:) + /// + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + public func isEligibleForIntroOfferIOS(groupID: String) async throws -> Bool { + let normalizedGroupID = groupID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedGroupID.isEmpty else { + return false + } + try await ensureConnection() + return await StoreKit.Product.SubscriptionInfo.isEligibleForIntroOffer(for: normalizedGroupID) + } + + /// Sync the user's in-app purchases with the App Store + /// - SeeAlso: https://developer.apple.com/documentation/storekit/appstore/sync() + /// + /// See: https://openiap.dev/docs/apis/ios/sync-ios + public func syncIOS() async throws -> Bool { + try await ensureConnection() + do { + try await AppStore.sync() + return true + } catch is CancellationError { + throw CancellationError() + } catch { + throw PurchaseError.wrap(error, fallback: .serviceError) + } + } + + /// Open the offer/promo code redemption flow (the App Store offer code + /// redemption sheet on Apple platforms). + /// - Note: Builds made with Xcode 27+ return the verified transaction on + /// iOS 27+, Mac Catalyst 27+, and visionOS 27+. Earlier iOS and visionOS + /// paths return nil after presenting the system sheet. Mac Catalyst 16–26 + /// throws StoreKitError.unknown, and Catalyst 15 returns nil after a + /// StoreKit 1 call that has no effect. + /// - SeeAlso: https://developer.apple.com/documentation/storekit/appstore/presentoffercoderedeemsheet(from:options:) + /// + /// See: https://openiap.dev/docs/apis/open-redeem-offer-code + public func openRedeemOfferCode() async throws -> PurchaseIOS? { + try await ensureConnection() + + #if compiler(>=6.4) + #if os(iOS) || os(visionOS) + if #available(iOS 27.0, macCatalyst 27.0, visionOS 27.0, *) { + guard let viewController = await activeViewController() else { + throw makePurchaseError( + code: .purchaseError, + message: "Cannot find an active view controller for offer-code redemption" + ) + } + + do { + let result = try await AppStore.presentOfferCodeRedeemSheet( + from: viewController, + options: [] + ) + let transaction = try checkVerified(result) + return await StoreKitTypesBridge.purchaseIOS( + from: transaction, + jwsRepresentation: result.jwsRepresentation + ) + } catch is CancellationError { + throw CancellationError() + } catch { + throw PurchaseError.wrap(error, fallback: .purchaseError) + } + } + #endif + #endif + + // StoreKit 2's scene-based sheet is available before the result-returning + // Apple 27 API, but cannot return the redeemed transaction directly. + #if os(iOS) || os(visionOS) + if #available(iOS 16.0, macCatalyst 16.0, visionOS 1.0, *) { + guard let scene = await activeWindowScene() else { + throw makePurchaseError( + code: .purchaseError, + message: "Cannot find an active window scene for offer-code redemption" + ) + } + do { + try await AppStore.presentOfferCodeRedeemSheet(in: scene) + return nil + } catch is CancellationError { + throw CancellationError() + } catch { + throw PurchaseError.wrap(error, fallback: .purchaseError) + } + } + #endif + + // iOS 15 requires the original StoreKit sheet. + #if os(iOS) + await MainActor.run { + SKPaymentQueue.default().presentCodeRedemptionSheet() + } + return nil + #else + throw makePurchaseError(code: .featureNotSupported) + #endif // os(iOS) + } + + /// Deprecated. Use openRedeemOfferCode instead. + /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + @available(*, deprecated, message: "Use openRedeemOfferCode. Scheduled for removal in client protocol 1.0.0.") + public func presentCodeRedemptionSheetIOS() async throws -> PurchaseIOS? { + try await openRedeemOfferCode() + } + + /// Present the manage-subscriptions sheet. + /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + public func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] { + #if os(macOS) + // deepLinkToSubscriptions can open a browser on macOS, but that call + // returns immediately and cannot observe changes made outside the app. + throw makePurchaseError(code: .featureNotSupported) + #else + let previousTransactions = try await getAllTransactionsIOS() + try await deepLinkToSubscriptions(nil) + let currentTransactions = try await getAllTransactionsIOS() + return Self.changedPurchasesIOS(currentTransactions, comparedTo: previousTransactions) + #endif + } + + static func changedPurchasesIOS( + _ current: [PurchaseIOS], + comparedTo previous: [PurchaseIOS] + ) -> [PurchaseIOS] { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + var previousById: [String: Data] = [:] + for purchase in previous { + if let data = try? encoder.encode(purchase) { + previousById[purchase.id] = data + } + } + + return current.filter { purchase in + guard let currentData = try? encoder.encode(purchase) else { return true } + return previousById[purchase.id] != currentData + } + } + + // MARK: - External Purchase (iOS 17.4+, macOS 14.4+, tvOS 17.4+, visionOS 1.1+) + + /// Check eligibility for the external purchase notice sheet (iOS 17.4+). + /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + public func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { + try await ensureConnection() + // iOS 17.4+, macOS 14.4+, tvOS 17.4+, watchOS 10.4+, visionOS 1.1+: ExternalPurchase.canPresent + // Reference: https://developer.apple.com/documentation/storekit/externalpurchase/canpresent + if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, visionOS 1.1, *) { + return await ExternalPurchase.canPresent + } else { + return false + } + } + + /// Present the external purchase notice sheet (iOS 17.4+). + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + public func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { + try await ensureConnection() + // iOS 17.4+, macOS 14.4+, tvOS 17.4+, watchOS 10.4+, visionOS 1.1+: ExternalPurchase.presentNoticeSheet + // Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() + if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, visionOS 1.1, *) { + guard await ExternalPurchase.canPresent else { + throw makePurchaseError( + code: .featureNotSupported, + message: "External purchase notice sheet is not available" + ) + } + + do { + let result = try await ExternalPurchase.presentNoticeSheet() + switch result { + case .continuedWithExternalPurchaseToken(let token): + // Return the token for reporting to Apple's External Purchase Server API + // The token is a String type in StoreKit + return ExternalPurchaseNoticeResultIOS( + error: nil, + externalPurchaseToken: token, + result: .continue + ) + case .cancelled: + // User dismissed the notice sheet + return ExternalPurchaseNoticeResultIOS( + error: nil, + externalPurchaseToken: nil, + result: .dismissed + ) + @unknown default: + // Handle future cases gracefully + throw makePurchaseError( + code: .unknown, + message: "Unexpected result from external purchase notice sheet" + ) + } + } catch let error as PurchaseError { + return ExternalPurchaseNoticeResultIOS( + error: error.message, + externalPurchaseToken: nil, + result: .dismissed + ) + } catch { + let purchaseError = makePurchaseError( + code: .serviceError, + message: "Failed to present external purchase notice: \(error.localizedDescription)" + ) + return ExternalPurchaseNoticeResultIOS( + error: purchaseError.message, + externalPurchaseToken: nil, + result: .dismissed + ) + } + } else { + throw makePurchaseError( + code: .featureNotSupported, + message: "External purchase notice sheet requires iOS 17.4+, macOS 14.4+, tvOS 17.4+, watchOS 10.4+, or visionOS 1.1+" + ) + } + } + + /// Present an external purchase link, StoreKit External (iOS 16+). + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + public func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { + try await ensureConnection() + // UIApplication.open is available on iOS/tvOS/visionOS but not watchOS/macOS + // Reference: https://developer.apple.com/documentation/uikit/uiapplication/1648685-open + #if os(iOS) || os(tvOS) || os(visionOS) + guard let customLink = URL(string: url) else { + return ExternalPurchaseLinkResultIOS( + error: "Invalid URL", + success: false + ) + } + + return await MainActor.run { + if UIApplication.shared.canOpenURL(customLink) { + UIApplication.shared.open(customLink, options: [:]) { success in + // Completion handler - link opened + } + return ExternalPurchaseLinkResultIOS(error: nil, success: true) + } else { + return ExternalPurchaseLinkResultIOS( + error: "Cannot open URL", + success: false + ) + } + } + #else + throw makePurchaseError(code: .featureNotSupported) + #endif // os(iOS) || os(tvOS) || os(visionOS) + } + + // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) + + /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + public func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool { + try await ensureConnection() + // iOS 18.1+: ExternalPurchaseCustomLink.isEligible + // Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, watchOS 11.1, visionOS 2.1, *) { + return await ExternalPurchaseCustomLink.isEligible + } else { + return false + } + } + + /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). + /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + public func getExternalPurchaseCustomLinkTokenIOS( + _ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS + ) async throws -> ExternalPurchaseCustomLinkTokenResultIOS { + try await ensureConnection() + // iOS 18.1+: ExternalPurchaseCustomLink.token(for:) + // Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, watchOS 11.1, visionOS 2.1, *) { + guard await ExternalPurchaseCustomLink.isEligible else { + return ExternalPurchaseCustomLinkTokenResultIOS( + error: "App is not eligible for ExternalPurchaseCustomLink", + token: nil + ) + } + + do { + // Token type is a String parameter, use enum's rawValue directly + guard let token = try await ExternalPurchaseCustomLink.token(for: tokenType.rawValue) else { + return ExternalPurchaseCustomLinkTokenResultIOS( + error: "Failed to retrieve external purchase token", + token: nil + ) + } + return ExternalPurchaseCustomLinkTokenResultIOS( + error: nil, + token: token.value + ) + } catch { + return ExternalPurchaseCustomLinkTokenResultIOS( + error: "Failed to get external purchase token: \(error.localizedDescription)", + token: nil + ) + } + } else { + throw makePurchaseError( + code: .featureNotSupported, + message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.1+, tvOS 18.1+, watchOS 11.1+, or visionOS 2.1+" + ) + } + } + + /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). + /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + public func showExternalPurchaseCustomLinkNoticeIOS( + _ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS + ) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS { + try await ensureConnection() + // iOS 18.1+: ExternalPurchaseCustomLink.showNotice(type:) + // Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, watchOS 11.1, visionOS 2.1, *) { + guard await ExternalPurchaseCustomLink.isEligible else { + return ExternalPurchaseCustomLinkNoticeResultIOS( + continued: false, + error: "App is not eligible for ExternalPurchaseCustomLink" + ) + } + + do { + let storeKitNoticeType: ExternalPurchaseCustomLink.NoticeType = switch noticeType { + case .browser: + .browser + } + + let result = try await ExternalPurchaseCustomLink.showNotice(type: storeKitNoticeType) + switch result { + case .continued: + return ExternalPurchaseCustomLinkNoticeResultIOS( + continued: true, + error: nil + ) + case .cancelled: + return ExternalPurchaseCustomLinkNoticeResultIOS( + continued: false, + error: nil + ) + @unknown default: + return ExternalPurchaseCustomLinkNoticeResultIOS( + continued: false, + error: "Unknown notice result" + ) + } + } catch { + return ExternalPurchaseCustomLinkNoticeResultIOS( + continued: false, + error: "Failed to show notice: \(error.localizedDescription)" + ) + } + } else { + throw makePurchaseError( + code: .featureNotSupported, + message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.1+, tvOS 18.1+, watchOS 11.1+, or visionOS 2.1+" + ) + } + } + + // MARK: - Event Listener Registration + + public func purchaseUpdatedListener( + _ listener: @escaping PurchaseUpdatedListener, + options: PurchaseUpdatedListenerOptions? = nil + ) -> Subscription { + let subscription = Subscription(eventType: .purchaseUpdated) + state.addPurchaseUpdatedListener( + id: subscription.id, + listener: listener, + options: options + ) + return subscription + } + + public func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription { + let subscription = Subscription(eventType: .purchaseError) + state.addPurchaseErrorListener((subscription.id, listener)) + return subscription + } + + public func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription { + let subscription = Subscription(eventType: .promotedProductIos) + let pendingSku = state.addPromotedProductListener((subscription.id, listener)) + if let pendingSku { + Task { + await MainActor.run { + listener(pendingSku) + } + } + } + return subscription + } + + public func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription { + let subscription = Subscription(eventType: .subscriptionBillingIssue) + withMessageListenerRegistrationLock { + state.addSubscriptionBillingIssueListener((subscription.id, listener)) + } + // Do not consume StoreKit's pending Message sequence before connection + // initialization. performInitConnection starts it when listeners exist; + // this task covers registration after an already-completed init. + Task { [weak self] in + guard let self, + await self.state.isInitialized, + let generation = self.connection.currentConnectedGeneration() else { return } + try? self.startMessageListenerIfRegistered(generation: generation) + } + return subscription + } + + public func removeListener(_ subscription: Subscription) { + withMessageListenerRegistrationLock { + state.removeListener(id: subscription.id, type: subscription.eventType) + if subscription.eventType == .subscriptionBillingIssue, + !state.hasSubscriptionBillingIssueListeners() { + connection.stopMessageListenerTask() + } + } + Task { await MainActor.run { subscription.onRemove?() } } + } + + public func removeAllListeners() { + withMessageListenerRegistrationLock { + state.removeAllListeners() + connection.stopMessageListenerTask() + } + } + + // MARK: - Private Helpers + + private func performInitConnection(generation: UInt64) async throws -> Bool { + try Task.checkCancellation() + try connection.ensureCurrent(generation) + + if await state.isInitialized { + try Task.checkCancellation() + try connection.ensureCurrent(generation) + return true + } + + _ = try connection.getOrCreateProductManager(generation: generation) + + registerPromotedPurchaseObserverIfNeeded() + + try Task.checkCancellation() + try connection.ensureCurrent(generation) + if await state.isInitialized { + try Task.checkCancellation() + try connection.ensureCurrent(generation) + return true + } + + guard AppStore.canMakePayments else { + emitPurchaseError(makePurchaseError(code: .iapNotAvailable)) + await state.setInitialized(false) + return false + } + + try Task.checkCancellation() + try connection.ensureCurrent(generation) + + await state.setInitialized(true) + try startTransactionListener(generation: generation) + try startUnfinishedTransactionProcessing(generation: generation) + try Task.checkCancellation() + try connection.ensureCurrent(generation) + try startMessageListenerIfRegistered(generation: generation) + try Task.checkCancellation() + try connection.ensureCurrent(generation) + return true + } + + private func cancelConnectionTasksForDeinit() { + let resources = connection.detachTasksForDeinit() + resources.initTask?.cancel() + resources.endTask?.cancel() + resources.updateListenerTask?.cancel() + resources.messageListenerTask?.cancel() + resources.unfinishedTransactionTask?.cancel() + } + + private func startPromotedPurchaseIntentListenerIfAvailableIOS() { + #if os(iOS) + if #available(iOS 16.4, macCatalyst 16.4, *) { + guard promotedPurchaseIntentTask == nil else { return } + promotedPurchaseIntentTask = Task { [weak self] in + for await intent in PurchaseIntent.intents { + guard !Task.isCancelled, let self else { return } + let offer: StoreKit.Product.SubscriptionOffer? + if #available(iOS 18.0, macCatalyst 18.0, *) { + offer = intent.offer + } else { + offer = nil + } + await self.promotedPurchaseIntentOffers.record( + offer, + for: intent.product.id + ) + if let productManager = self.connection.currentProductManager() { + await productManager.addProduct(intent.product) + } + self.emitPromotedProduct(intent.product.id) + } + } + } + #endif + } + + private func registerPromotedPurchaseObserverIfNeeded() { + #if os(iOS) + if #available(iOS 16.4, macCatalyst 16.4, *) { return } + + promotedPurchaseObserverLock.lock() + let shouldRegister = !didRegisterPromotedPurchaseObserver && + !isPromotedPurchaseObserverTransitionInFlight + if shouldRegister { + isPromotedPurchaseObserverTransitionInFlight = true + } + promotedPurchaseObserverLock.unlock() + + guard shouldRegister else { return } + + let addObserver = { + SKPaymentQueue.default().add(self) + } + + if Thread.isMainThread { + addObserver() + } else { + DispatchQueue.main.sync(execute: addObserver) + } + + promotedPurchaseObserverLock.lock() + didRegisterPromotedPurchaseObserver = true + isPromotedPurchaseObserverTransitionInFlight = false + promotedPurchaseObserverLock.unlock() + #endif // os(iOS) + } + + private func unregisterPromotedPurchaseObserverIfNeeded() { + #if os(iOS) + if #available(iOS 16.4, macCatalyst 16.4, *) { return } + + promotedPurchaseObserverLock.lock() + let shouldUnregister = didRegisterPromotedPurchaseObserver && + !isPromotedPurchaseObserverTransitionInFlight + if shouldUnregister { + isPromotedPurchaseObserverTransitionInFlight = true + } + promotedPurchaseObserverLock.unlock() + + guard shouldUnregister else { return } + + let removeObserver = { + SKPaymentQueue.default().remove(self) + } + + if Thread.isMainThread { + removeObserver() + } else { + DispatchQueue.main.sync(execute: removeObserver) + } + + promotedPurchaseObserverLock.lock() + didRegisterPromotedPurchaseObserver = false + isPromotedPurchaseObserverTransitionInFlight = false + promotedPurchaseObserverLock.unlock() + #endif // os(iOS) + } + + private func ensureConnection() async throws { + try Task.checkCancellation() + guard AppStore.canMakePayments else { + let error = makePurchaseError(code: .iapNotAvailable) + emitPurchaseError(error) + throw error + } + + while true { + try Task.checkCancellation() + let connected = await hasInitializedConnection() + try Task.checkCancellation() + if connected { return } + if let endTask = connection.currentEndTask() { + await endTask.value + continue + } + + let initialized = try await initConnection() + try Task.checkCancellation() + if initialized, await hasInitializedConnection() { return } + if connection.currentEndTask() != nil { continue } + + let error = makePurchaseError(code: .initConnection) + emitPurchaseError(error) + throw error + } + } + + private func hasInitializedConnection() async -> Bool { + guard let generation = connection.currentConnectedGeneration(), + await state.isInitialized else { return false } + return connection.isConnected(generation: generation) + } + + private func cleanupExistingState() async { + let resources = connection.detachResourcesForCleanup() + resources.updateListenerTask?.cancel() + resources.messageListenerTask?.cancel() + resources.unfinishedTransactionTask?.cancel() + if let updateListenerTask = resources.updateListenerTask { + _ = try? await updateListenerTask.value + } + if let messageListenerTask = resources.messageListenerTask { + await messageListenerTask.value + } + if let unfinishedTransactionTask = resources.unfinishedTransactionTask { + await unfinishedTransactionTask.value + } + await state.reset() + if let manager = resources.productManager { await manager.removeAll() } + } + + private func storeProduct(for sku: String) async throws -> StoreKit.Product { + guard let productManager = connection.currentProductManager() else { + let error = makePurchaseError(code: .notPrepared) + emitPurchaseError(error) + throw error + } + + if let product = await productManager.getProduct(productID: sku) { + return product + } + + let products = try await StoreKit.Product.products(for: [sku]) + guard let first = products.first else { + let error = makePurchaseError(code: .skuNotFound, productId: sku) + emitPurchaseError(error) + throw error + } + await productManager.addProduct(first) + return first + } + + /// Resolves iOS purchase props from request params. + /// Returns either RequestPurchaseIosProps or RequestSubscriptionIosProps based on request type. + func resolveIOSPurchaseProps(from params: RequestPurchaseProps) throws -> any IosPropsProtocol { + try Self.validateIOSPurchaseProps(params) + switch params.request { + case let .purchase(platforms): + if let ios = platforms.apple { + return ios + } + case let .subscription(platforms): + if let ios = platforms.apple { + return ios + } + } + throw makePurchaseError(code: .purchaseError, message: "Missing iOS purchase parameters") + } + + static func validateIOSPurchaseProps(_ params: RequestPurchaseProps) throws { + switch params.request { + case .purchase where params.type == .inApp: + return + case .subscription where params.type == .subs: + return + case .purchase: + throw PurchaseError.make( + code: .developerError, + message: "type must be in-app when requestPurchase is provided" + ) + case .subscription: + throw PurchaseError.make( + code: .developerError, + message: "type must be subs when requestSubscription is provided" + ) + } + } + + private func purchaseProductId(from params: RequestPurchaseProps) -> String? { + switch params.request { + case let .purchase(platforms): + return platforms.apple?.sku + case let .subscription(platforms): + return platforms.apple?.sku + } + } + + private func startTransactionListener(generation: UInt64) throws { + try connection.startTransactionListenerTask(generation: generation) { + OpenIapLog.debug("🎧 [TransactionListener] Starting Transaction.updates listener...") + return Task { [weak self] in + guard let self else { + OpenIapLog.debug("⚠️ [TransactionListener] Self is nil, exiting listener") + return + } + OpenIapLog.debug("✅ [TransactionListener] Listener task started, waiting for transactions...") + for await verification in Transaction.updates { + guard !Task.isCancelled, + self.connection.isCurrentGeneration(generation) else { return } + do { + guard await self.state.isInitialized else { continue } + guard self.connection.isCurrentGeneration(generation) else { return } + let transaction = try self.checkVerified(verification) + let transactionId = String(transaction.id) + + // Log all transaction details for debugging + OpenIapLog.debug(""" + 📦 Transaction received: + - ID: \(transactionId) + - Product: \(transaction.productID) + - purchaseDate: \(transaction.purchaseDate) + - subscriptionGroupID: \(transaction.subscriptionGroupID ?? "nil") + - revocationDate: \(transaction.revocationDate?.description ?? "nil") + """) + + if StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( + transaction.productType + ), + self.isInactiveSubscriptionTransaction(transaction) { + await transaction.finish() + guard self.connection.isCurrentGeneration(generation) else { return } + await self.state.removePending(id: transactionId) + OpenIapLog.debug(""" + 🧹 [TransactionListener] Finished inactive subscription update without emitting: + - SKU: \(transaction.productID) + - Transaction ID: \(transaction.id) + - Expiration: \(transaction.expirationDate?.description ?? "none") + - Revoked: \(transaction.revocationDate?.description ?? "none") + - Upgraded: \(transaction.isUpgraded) + """) + continue + } + + if transaction.revocationDate != nil { + OpenIapLog.debug("⏭️ Skipping revoked transaction: \(transactionId)") + continue + } + + let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) + guard self.connection.isCurrentGeneration(generation) else { return } + + // Default listeners receive each transaction id once per connection + // session. Non-deduping listeners can opt into StoreKit replays. + guard await self.state.recordPurchaseUpdateEmission( + id: transactionId, + pendingTransaction: transaction + ) else { + guard self.connection.isCurrentGeneration(generation) else { return } + self.emitPurchaseUpdate( + purchase, + isDuplicate: true, + duplicateSource: "Transaction.updates", + duplicateTransactionId: transactionId + ) + continue + } + guard self.connection.isCurrentGeneration(generation) else { return } + OpenIapLog.debug("✅ [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") + self.emitPurchaseUpdate(purchase) + } catch { + guard self.connection.isCurrentGeneration(generation) else { return } + let purchaseError: PurchaseError + if let existing = error as? PurchaseError { + purchaseError = existing + } else { + purchaseError = makePurchaseError(code: .transactionValidationFailed, message: error.localizedDescription) + } + self.emitPurchaseError(purchaseError) + } + } + } + } + } + + private func startUnfinishedTransactionProcessing(generation: UInt64) throws { + try connection.startUnfinishedTransactionTask(generation: generation) { + Task { [weak self] in + guard let self else { return } + defer { self.connection.clearUnfinishedTransactionTask(generation: generation) } + await self.processUnfinishedTransactions(generation: generation) + } + } + } + + private func processUnfinishedTransactions(generation: UInt64) async { + for await verification in Transaction.unfinished { + guard !Task.isCancelled, + connection.isCurrentGeneration(generation) else { return } + guard await state.isInitialized else { return } + guard connection.isCurrentGeneration(generation) else { return } + do { + let transaction = try checkVerified(verification) + if StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( + transaction.productType + ), + isInactiveSubscriptionTransaction(transaction) { + await transaction.finish() + guard connection.isCurrentGeneration(generation) else { return } + await state.removePending(id: String(transaction.id)) + OpenIapLog.debug(""" + 🧹 [processUnfinishedTransactions] Finished inactive subscription transaction: + - SKU: \(transaction.productID) + - Transaction ID: \(transaction.id) + - Expiration: \(transaction.expirationDate?.description ?? "none") + - Revoked: \(transaction.revocationDate?.description ?? "none") + - Upgraded: \(transaction.isUpgraded) + """) + continue + } + guard connection.isCurrentGeneration(generation) else { return } + await state.storePending(id: String(transaction.id), transaction: transaction) + } catch { + continue + } + } + } + + private func finishInactiveUnfinishedSubscriptions(productId: String) async { + for await verification in Transaction.unfinished { + if Task.isCancelled { return } + + do { + let transaction = try checkVerified(verification) + guard StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( + transaction.productType + ), + transaction.productID == productId, + isInactiveSubscriptionTransaction(transaction) + else { + continue + } + + await transaction.finish() + await state.removePending(id: String(transaction.id)) + OpenIapLog.debug(""" + 🧹 [requestPurchase] Cleared inactive unfinished subscription before purchase: + - SKU: \(transaction.productID) + - Transaction ID: \(transaction.id) + - Expiration: \(transaction.expirationDate?.description ?? "none") + - Revoked: \(transaction.revocationDate?.description ?? "none") + - Upgraded: \(transaction.isUpgraded) + """) + } catch { + OpenIapLog.debug("⚠️ Failed to clear inactive unfinished subscription: \(error.localizedDescription)") + continue + } + } + } + + private func preflightInactiveUnfinishedSubscriptions(productId: String) async { + let outcome = await withTaskGroup( + of: SubscriptionPreflightOutcome.self, + returning: SubscriptionPreflightOutcome.self + ) { group in + group.addTask { [weak self] in + guard let self else { return .completed } + await self.finishInactiveUnfinishedSubscriptions(productId: productId) + return .completed + } + + group.addTask { + do { + try await Task.sleep(nanoseconds: Self.subscriptionPreflightTimeoutNanoseconds) + return .timedOut + } catch { + return .completed + } + } + + let outcome = await group.next() ?? .completed + group.cancelAll() + return outcome + } + + if case .timedOut = outcome { + OpenIapLog.debug("⚠️ [requestPurchase] Inactive subscription cleanup timed out; continuing purchase flow") + } + } + + private func isInactiveSubscriptionTransaction(_ transaction: StoreKit.Transaction) -> Bool { + if transaction.revocationDate != nil || transaction.isUpgraded { + return true + } + + if let expirationDate = transaction.expirationDate { + return expirationDate <= Date() + } + + return false + } + + func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .verified(let value): + return value + case .unverified: + throw makePurchaseError(code: .transactionValidationFailed, message: "Transaction verification failed") + } + } + + private func logDuplicatePurchaseUpdate( + source: String, + transactionId: String, + productId: String, + listenerCount: Int + ) { + let action = listenerCount > 0 + ? "Delivered duplicate purchase-updated event to \(listenerCount) non-deduping listener(s)." + : "Suppressed duplicate purchase-updated listener emission." + let message = """ + [PurchaseUpdateDedup] \(action) + - Source: \(source) + - Product: \(productId) + - Transaction ID: \(transactionId) + - Reason: this transaction id was already emitted during the current connection session. + - Scope: listeners dedupe by transaction id by default; listeners registered with dedupeTransactionIOS: false receive StoreKit replays. + """ + if listenerCount > 0 { + OpenIapLog.info(message) + } else { + OpenIapLog.debug(message) + } + } + + private func emitPurchaseUpdate( + _ purchase: Purchase, + isDuplicate: Bool = false, + duplicateSource: String? = nil, + duplicateTransactionId: String? = nil + ) { + Task { [state] in + let listeners = state.snapshotPurchaseUpdated(isDuplicate: isDuplicate) + if isDuplicate { + self.logDuplicatePurchaseUpdate( + source: duplicateSource ?? "unknown", + transactionId: duplicateTransactionId ?? purchase.id, + productId: purchase.productId, + listenerCount: listeners.count + ) + if listeners.isEmpty { + return + } + } + OpenIapLog.debug("✅ Emitting purchase update: Product=\(purchase.productId), Listeners=\(listeners.count)") + await MainActor.run { + listeners.forEach { $0(purchase) } + } + } + } + + private func emitPurchaseError(_ error: PurchaseError) { + guard !Self.suppressPurchaseErrorEmission else { return } + Task { await deliverPurchaseError(error) } + } + + private func deliverPurchaseError(_ error: PurchaseError) async { + let listeners = state.snapshotPurchaseError() + await MainActor.run { + listeners.forEach { $0(error) } + } + } + + private func emitPromotedProduct(_ sku: String) { + Task { [state] in + let listeners = await state.recordPromotedProductAndSnapshotListeners(sku) + await MainActor.run { + listeners.forEach { $0(sku) } + } + } + } + + private func emitSubscriptionBillingIssue(_ purchase: Purchase) { + let listeners = state.snapshotSubscriptionBillingIssue() + Task { + await MainActor.run { + listeners.forEach { $0(purchase) } + } + } + } + + /// Starts the StoreKit 2 Message listener for subscription-billing-issue events. + /// + /// The `.billingIssue` reason (what we care about) ships on iOS 16.4+, Mac Catalyst + /// 16.4+, and visionOS 1.0+, so this method starts the `Message.messages` loop when + /// that availability holds. On macOS, tvOS, and watchOS the Message API is unavailable, + /// making this a silent no-op on those platforms. + /// + /// References: + /// - https://developer.apple.com/documentation/storekit/message + /// - https://developer.apple.com/documentation/storekit/message/reason-swift.struct/billingissue + private func startMessageListenerIfRegistered(generation: UInt64) throws { + try withMessageListenerRegistrationLock { + guard state.hasSubscriptionBillingIssueListeners() else { return } + try startMessageListener(generation: generation) + } + } + + private func startMessageListener(generation: UInt64) throws { + #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) + if #available(iOS 16.4, macCatalyst 16.4, visionOS 1.0, *) { + try connection.startMessageListenerTask(generation: generation) { + OpenIapLog.debug("🔔 [MessageListener] Starting Message.messages listener") + return Task { [weak self] in + guard let self else { return } + for await message in StoreKit.Message.messages { + guard !Task.isCancelled, + self.connection.isConnected(generation: generation), + await self.state.isInitialized else { return } + OpenIapLog.debug("🔔 [MessageListener] Received message: reason=\(message.reason)") + + // Listening to Message.messages transfers presentation control to + // the app. Preserve StoreKit's default UX for every reason (including + // price-increase consent and win-back offers) instead of silently + // suppressing messages that OpenIAP does not expose as events. + guard await self.displayStoreKitMessage(message), + !Task.isCancelled, + self.connection.isConnected(generation: generation), + await self.state.isInitialized else { return } + + guard case .billingIssue = message.reason else { + OpenIapLog.debug("🔔 [MessageListener] Skipping non-billingIssue message") + continue + } + OpenIapLog.debug("🔔 [MessageListener] billingIssue received — dispatching") + await self.dispatchBillingIssueMessage(generation: generation) + } + } + } + } else { + OpenIapLog.debug("🔔 [MessageListener] Skipped — Message.billingIssue unavailable") + } + #else + OpenIapLog.debug("🔔 [MessageListener] Skipped — unsupported platform") + #endif + } + + #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) + @available(iOS 16.0, macCatalyst 16.0, visionOS 1.0, *) + @MainActor + private func displayStoreKitMessage(_ message: StoreKit.Message) async -> Bool { + var retryDelay: UInt64 = 500_000_000 + while !Task.isCancelled { + if let scene = activeWindowScene(), scene.activationState == .foregroundActive { + do { + try message.display(in: scene) + return true + } catch { + OpenIapLog.warn("🔔 [MessageListener] StoreKit message display deferred: \(error.localizedDescription)") + } + } + + do { + try await Task.sleep(nanoseconds: retryDelay) + } catch { + return false + } + retryDelay = min(retryDelay * 2, 2_000_000_000) + } + return false + } + #endif + + /// Resolves the affected subscription group(s) from transaction history and emits the event. + /// + /// `StoreKit.Message` doesn't carry a transaction reference. Billing-retry subscriptions + /// are not necessarily current entitlements, so use `Transaction.all` only to discover the + /// user's subscription-group IDs, then treat `Product.SubscriptionInfo.status(for:)` as the + /// authoritative source. The status array is unordered across group members, so every + /// retry/grace entry and its own verified transaction must be inspected. + /// + /// Reference: https://developer.apple.com/documentation/storekit/product/subscriptioninfo/status(for:) + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) + private func dispatchBillingIssueMessage(generation: UInt64) async { + guard !Task.isCancelled else { return } + var subscriptionGroupIds = Set() + for await verification in Transaction.all { + guard !Task.isCancelled else { return } + guard case .verified(let transaction) = verification, + StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( + transaction.productType + ), + let groupId = transaction.subscriptionGroupID else { continue } + subscriptionGroupIds.insert(groupId) + } + guard !subscriptionGroupIds.isEmpty else { + OpenIapLog.debug("🔔 [MessageListener] billingIssue received but no auto-renewable transaction history is present") + return + } + + var emitted = false + var emittedTransactionIds = Set() + for groupId in subscriptionGroupIds { + guard !Task.isCancelled else { return } + let statusArray: [StoreKit.Product.SubscriptionInfo.Status] + do { + statusArray = try await StoreKit.Product.SubscriptionInfo.status(for: groupId) + } catch { + OpenIapLog.debug("🔔 [MessageListener] Subscription status failed for group \(groupId): \(error.localizedDescription)") + continue + } + for status in statusArray { + guard !Task.isCancelled else { return } + guard status.state == .inBillingRetryPeriod || status.state == .inGracePeriod, + case .verified(let transaction) = status.transaction, + emittedTransactionIds.insert(transaction.id).inserted else { continue } + let purchase = await StoreKitTypesBridge.purchase( + from: transaction, + jwsRepresentation: status.transaction.jwsRepresentation + ) + guard !Task.isCancelled, + connection.isConnected(generation: generation), + await state.isInitialized else { return } + emitSubscriptionBillingIssue(purchase) + emitted = true + } + } + if !emitted { + OpenIapLog.debug("🔔 [MessageListener] billingIssue received but no subscription currently reports retry/grace state") + } + } + + private func makePurchaseError( + code: ErrorCode, + productId: String? = nil, + message: String? = nil, + debugMessage: String? = nil + ) -> PurchaseError { + PurchaseError( + code: code, + debugMessage: debugMessage, + message: message ?? defaultMessage(for: code), + productId: productId + ) + } + + private func defaultMessage(for code: ErrorCode) -> String { + switch code { + case .unknown: return "Unknown error occurred" + case .userCancelled: return "User cancelled the purchase flow" + case .userError: return "User action error" + case .itemUnavailable: return "Item unavailable" + case .remoteError: return "Remote service error" + case .networkError: return "Network connection error" + case .serviceError: return "Store service error" + case .purchaseVerificationFailed: return "Purchase verification failed" + case .purchaseVerificationFinished: return "Transaction already finished" + case .purchaseVerificationFinishFailed: return "Transaction finish failed" + case .notPrepared: return "Billing is not prepared" + case .notEnded: return "Billing connection not ended" + case .alreadyOwned: return "Item already owned" + case .developerError: return "Developer configuration error" + case .billingResponseJsonParseError: return "Failed to parse billing response" + case .deferredPayment: return "Payment was deferred (pending approval)" + case .interrupted: return "Purchase flow interrupted" + case .iapNotAvailable: return "In-app purchases not available on this device" + case .purchaseError: return "Purchase error" + case .syncError: return "Sync error" + case .transactionValidationFailed: return "Transaction validation failed" + case .activityUnavailable: return "Required activity is unavailable" + case .alreadyPrepared: return "Billing already prepared" + case .pending: return "Transaction pending" + case .connectionClosed: return "Connection closed" + case .initConnection: return "Failed to initialize billing connection" + case .serviceDisconnected: return "Billing service disconnected" + case .serviceTimeout: return "Billing service request timed out" + case .queryProduct: return "Failed to query product" + case .skuNotFound: return "SKU not found" + case .skuOfferMismatch: return "SKU offer mismatch" + case .itemNotOwned: return "Item not owned" + case .billingUnavailable: return "Billing unavailable" + case .featureNotSupported: return "Feature not supported on this platform" + case .emptySkuList: return "Empty SKU list provided" + case .duplicatePurchase: return "Duplicate purchase update detected" + } + } + + private func withMessageListenerRegistrationLock(_ body: () throws -> T) rethrows -> T { + messageListenerRegistrationLock.lock() + defer { messageListenerRegistrationLock.unlock() } + return try body() + } + + #if os(iOS) || os(tvOS) || os(visionOS) + @available(iOS 17.0, tvOS 17.0, visionOS 1.0, *) + @MainActor + private func purchaseWithActiveScene( + product: StoreKit.Product, + options: Set, + sku: String + ) async throws -> StoreKit.Product.PurchaseResult { + guard let scene = activeWindowScene() else { + let error = makePurchaseError(code: .purchaseError, productId: sku, message: "Could not find active window scene") + emitPurchaseError(error) + throw error + } + + OpenIapLog.debug(""" + 🛒 [requestPurchase] Presenting StoreKit purchase sheet: + - SKU: \(sku) + - Scene state: \(scene.activationState.rawValue) + """) + return try await product.purchase(confirmIn: scene, options: options) + } + + @MainActor + private func activeWindowScene() -> UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.first { $0.activationState == .foregroundActive } + ?? scenes.first { $0.activationState == .foregroundInactive } + ?? scenes.first + } + + @MainActor + private func activeViewController() -> UIViewController? { + guard let scene = activeWindowScene() else { + return nil + } + let window = scene.windows.first(where: \.isKeyWindow) ?? scene.windows.first + return topViewController(from: window?.rootViewController) + } + + @MainActor + private func topViewController(from root: UIViewController?) -> UIViewController? { + if let presented = root?.presentedViewController { + return topViewController(from: presented) + } + if let navigation = root as? UINavigationController { + return topViewController(from: navigation.visibleViewController) + } + if let tab = root as? UITabBarController { + return topViewController(from: tab.selectedViewController) + } + return root + } + #endif + + @available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *) + private func mapAppTransaction(_ transaction: StoreKit.AppTransaction) -> AppTransaction { + let appVersionId = transaction.appVersionID.map(Double.init) ?? 0 + let appVersion = transaction.appVersion + let appId = transaction.appID.map(Double.init) ?? 0 + + // iOS 18.4+ properties - only compile with Xcode 16.4+ (Swift 6.1 compiler+) + // This prevents build failures on Xcode 16.3 and below + var appTransactionId: String? = nil + var originalPlatformValue: String? = nil + var revocationDateValue: Double? = nil + var storeTypeValue: String? = nil + + // Swift 6.1 compiler+ (Xcode 16.4+): appTransactionID is back-deployed + // to the AppTransaction baseline; originalPlatform itself starts at 18.4. + #if compiler(>=6.1) + appTransactionId = transaction.appTransactionID + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { + originalPlatformValue = transaction.originalPlatform.rawValue + } else { + originalPlatformValue = transaction.originalPlatformStringRepresentation + } + #endif // compiler(>=6.1) + + #if compiler(>=6.4) + // Xcode 27 first publishes this property, but StoreKit declares it + // back-deployed to the AppTransaction baseline runtimes. + revocationDateValue = transaction.revocationDate?.milliseconds + + if #available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *) { + storeTypeValue = transaction.storeType.rawValue + } + #endif + + return AppTransaction( + appId: appId, + appTransactionId: appTransactionId, + appVersion: appVersion, + appVersionId: appVersionId, + bundleId: transaction.bundleID, + deviceVerification: transaction.deviceVerification.base64EncodedString(), + deviceVerificationNonce: transaction.deviceVerificationNonce.uuidString, + environment: transaction.environment.rawValue, + originalAppVersion: transaction.originalAppVersion, + originalPlatform: originalPlatformValue, + originalPurchaseDate: transaction.originalPurchaseDate.milliseconds, + preorderDate: transaction.preorderDate?.milliseconds, + revocationDate: revocationDateValue, + signedDate: transaction.signedDate.milliseconds, + storeType: storeTypeValue + ) + } +} + +// iOS-only: SKPaymentTransactionObserver extension for promoted in-app purchases +// Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases +#if os(iOS) +extension OpenIapStoreKitModule: SKPaymentTransactionObserver { + public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + // StoreKit 2 handles transactions via Transaction.updates; nothing to do here. + } + + public func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { + Task { [weak self] in + guard let self else { return } + self.emitPromotedProduct(product.productIdentifier) + } + return false + } +} +#endif // os(iOS) diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index 5080bd6fc..6a7d762b5 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -27,14 +27,14 @@ final class OpenIapTests: XCTestCase { func testTransactionVerificationFailureIsNotAnEmptyEntitlement() throws { XCTAssertEqual( - try OpenIapModule.shared.checkVerified( + try OpenIapStoreKitModule().checkVerified( VerificationResult.verified(7) ), 7 ) XCTAssertThrowsError( - try OpenIapModule.shared.checkVerified( + try OpenIapStoreKitModule().checkVerified( VerificationResult.unverified(7, .invalidSignature) ) ) { error in @@ -460,6 +460,8 @@ final class OpenIapTests: XCTestCase { revocationTypeIOS: "assignmentRevocation", store: .apple, storefrontCountryCodeIOS: "US", + + storeId: "apple", subscriptionGroupIdIOS: "21686373", transactionDate: 1729083955000, transactionId: "2000001034753679", @@ -872,7 +874,7 @@ final class OpenIapTests: XCTestCase { )) ) - let resolved = try OpenIapModule.shared.resolveIOSPurchaseProps(from: props) + let resolved = try OpenIapStoreKitModule().resolveIOSPurchaseProps(from: props) XCTAssertEqual(resolved.sku, "dev.hyo.apple") } @@ -886,7 +888,7 @@ final class OpenIapTests: XCTestCase { )) ) - let resolved = try OpenIapModule.shared.resolveIOSPurchaseProps(from: props) + let resolved = try OpenIapStoreKitModule().resolveIOSPurchaseProps(from: props) XCTAssertEqual(resolved.sku, "dev.hyo.sub.apple") } @@ -901,7 +903,7 @@ final class OpenIapTests: XCTestCase { props.type = .all XCTAssertThrowsError( - try OpenIapModule.shared.resolveIOSPurchaseProps(from: props) + try OpenIapStoreKitModule().resolveIOSPurchaseProps(from: props) ) { error in XCTAssertEqual((error as? PurchaseError)?.code, .developerError) } @@ -1384,6 +1386,8 @@ final class OpenIapTests: XCTestCase { revocationTypeIOS: nil, store: .apple, storefrontCountryCodeIOS: "US", + + storeId: "apple", subscriptionGroupIdIOS: "group", transactionDate: 2, transactionId: "transaction", @@ -1422,6 +1426,8 @@ final class OpenIapTests: XCTestCase { revocationTypeIOS: nil, store: .apple, storefrontCountryCodeIOS: "US", + + storeId: "apple", subscriptionGroupIdIOS: "21686373", transactionDate: 1729083955000, transactionId: "2000001034753679", diff --git a/packages/apple/Tests/OpenIapTests/CompilerGuardTests.swift b/packages/apple/Tests/OpenIapTests/CompilerGuardTests.swift index da1208c0a..3713fe9ba 100644 --- a/packages/apple/Tests/OpenIapTests/CompilerGuardTests.swift +++ b/packages/apple/Tests/OpenIapTests/CompilerGuardTests.swift @@ -8,7 +8,7 @@ final class CompilerGuardTests: XCTestCase { .deletingLastPathComponent() let sourceFiles = [ "Sources/Helpers/StoreKitTypesBridge.swift", - "Sources/OpenIapModule.swift", + "Sources/OpenIapStoreKitModule.swift", ] let forbiddenGuard = "#if " + "swi" + "ft(>=" let forbiddenEndifComment = "#endif // " + "swi" + "ft(" diff --git a/packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift b/packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift index e688aab61..1eb47bc04 100644 --- a/packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift +++ b/packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift @@ -3,7 +3,7 @@ // packages/conformance/src/spec/behaviors.mjs. enum ConformanceBehaviors { - static let suiteVersion = "3.0.0" + static let suiteVersion = "4.0.0" static let productsFetchReturnsRequestedSkus = "products.fetch-returns-requested-skus" static let productsFetchNormalizesRequiredFields = "products.fetch-normalizes-required-fields" @@ -42,4 +42,9 @@ enum ConformanceBehaviors { static let identifiersPurchaseTokenIsStableAcrossReads = "identifiers.purchase-token-is-stable-across-reads" static let capabilitiesUnsupportedOperationsDegradePredictably = "capabilities.unsupported-operations-degrade-predictably" static let capabilitiesDeclaredCapabilitiesMatchTheMatrix = "capabilities.declared-capabilities-match-the-matrix" + static let providerInvalidPurchaseEmitsErrorOnce = "provider.invalid-purchase-emits-error-once" + static let androidProviderSubscriptionBillingIssue = "android-provider.subscription-billing-issue" + static let androidProviderOfferCodeRedemption = "android-provider.offer-code-redemption" + static let appleProviderSubscriptionBillingIssue = "apple-provider.subscription-billing-issue" + static let appleProviderOfferCodeRedemption = "apple-provider.offer-code-redemption" } diff --git a/packages/apple/Tests/OpenIapTests/ProviderDiscoveryTests.swift b/packages/apple/Tests/OpenIapTests/ProviderDiscoveryTests.swift new file mode 100644 index 000000000..285f3a043 --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/ProviderDiscoveryTests.swift @@ -0,0 +1,303 @@ +import Foundation +import XCTest +@testable import OpenIAP + +final class ProviderDiscoveryTests: XCTestCase { + func testNativeVersionCompatibility() throws { + for (built, runtime) in [("3.6.1", "3.6.2"), ("4.0.0", "4.1.0"), ("4.0.0-rc.1", "4.0.0-rc.1")] { + try OpenIapProvider.validate(storeId: "community-fixture", providerCoreVersion: built, runtimeCoreVersion: runtime) + } + for (built, runtime) in [("3.6.3", "3.6.2"), ("3.6.1", "4.0.0"), ("4.0.0-rc.1", "4.0.0"), ("4.0.0", "4.0.0-rc.1"), ("bad", "4.0.0"), ("4.0", "4.0.0")] { + XCTAssertThrowsError(try OpenIapProvider.validate(storeId: "community-fixture", providerCoreVersion: built, runtimeCoreVersion: runtime)) + } + for id in ["", "unknown", "google", "play", "none", "auto", "amazon", "horizon", "Bad id", "store\n"] { + XCTAssertThrowsError(try OpenIapProvider.validate(storeId: id, providerCoreVersion: "4.0.0", runtimeCoreVersion: "4.0.0")) + } + XCTAssertThrowsError(try OpenIapProvider.validate(storeId: "community-fixture", providerCoreVersion: "bad", runtimeCoreVersion: "4.0.0")) { error in + XCTAssertEqual( + (error as? PurchaseError)?.message, + "Invalid core version 'bad' for provider 'community-fixture'. Use a complete semantic version." + ) + } + XCTAssertThrowsError(try OpenIapProvider.validate(storeId: "community-fixture", providerCoreVersion: "3.6.3", runtimeCoreVersion: "3.6.2")) { error in + XCTAssertEqual( + (error as? PurchaseError)?.message, + "Provider 'community-fixture' requires OpenIAP 3.6.3; this app links 3.6.2. Use a compatible provider or core version." + ) + } + } + + func testDescriptorChecksBothContractsAndPlatform() throws { + let descriptor = OpenIapAppleProviderFactory().descriptor + XCTAssertEqual(descriptor.platform, .ios) + XCTAssertEqual(descriptor.clientProtocolVersion, "0.2.0") + try OpenIapProvider.validate(descriptor) + var invalid = descriptor + invalid.platform = .android + XCTAssertThrowsError(try OpenIapProvider.validate(invalid)) + invalid = descriptor + invalid.clientProtocolVersion = "0.1.1" + XCTAssertThrowsError(try OpenIapProvider.validate(invalid)) { error in + XCTAssertEqual( + (error as? PurchaseError)?.message, + "Provider '\(descriptor.storeId)' must implement Client Protocol \(OpenIapVersion.clientProtocolVersion)." + ) + } + invalid.clientProtocolVersion = "0.3.0" + XCTAssertThrowsError(try OpenIapProvider.validate(invalid)) { error in + XCTAssertEqual( + (error as? PurchaseError)?.message, + "Provider '\(descriptor.storeId)' requires Client Protocol 0.3.0; this app links \(OpenIapVersion.clientProtocolVersion). Update the provider or the OpenIAP runtime to a matching protocol." + ) + } + invalid.clientProtocolVersion = "bad" + XCTAssertThrowsError(try OpenIapProvider.validate(invalid)) { error in + XCTAssertEqual( + (error as? PurchaseError)?.message, + "Invalid Client Protocol version 'bad' for provider '\(descriptor.storeId)'. Use a complete semantic version." + ) + } + invalid = descriptor + invalid.coreVersion = "999.0.0" + XCTAssertThrowsError(try OpenIapProvider.validate(invalid)) { error in + XCTAssertEqual( + (error as? PurchaseError)?.message, + "Provider '\(descriptor.storeId)' requires OpenIAP 999.0.0; this app links \(OpenIapProvider.coreVersion). Use a compatible provider or core version." + ) + } + } + + func testOfficialCapabilitiesMatchAvailablePlatformFeatures() { + let capabilities = OpenIapAppleProviderFactory().capabilities + XCTAssertTrue(capabilities.contains("pendingPurchases")) + #if os(macOS) || os(tvOS) || os(watchOS) + XCTAssertEqual(capabilities, ["pendingPurchases"]) + #else + #if targetEnvironment(macCatalyst) + #if compiler(>=6.4) + if #available(macCatalyst 27.0, *) { XCTAssertTrue(capabilities.contains("offerCodeRedemption")) } + else { XCTAssertFalse(capabilities.contains("offerCodeRedemption")) } + #else + XCTAssertFalse(capabilities.contains("offerCodeRedemption")) + #endif + #else + XCTAssertTrue(capabilities.contains("offerCodeRedemption")) + #endif + if #available(iOS 16.4, macCatalyst 16.4, visionOS 1.0, *) { + XCTAssertTrue(capabilities.contains("subscriptionBillingIssue")) + } else { + XCTAssertFalse(capabilities.contains("subscriptionBillingIssue")) + } + #endif + } + + func testAbsentSelectionUsesOfficialFactory() throws { + try withBundle(value: nil) { bundle in + XCTAssertTrue(try OpenIapProvider.factory(bundle: bundle) is OpenIapAppleProviderFactory) + } + } + + func testMalformedSelectionFailsAtConnectionWithoutFallback() async throws { + let purchase = try OpenIapSerialization.purchaseInput(from: [ + "store": "apple", "id": "txn", "transactionId": "txn", "productId": "sku", + "quantity": 1, "isAutoRenewing": false, "purchaseState": "purchased", "transactionDate": 1.0, + ]) + for value in ["", "MissingFactory", "NSObject", 123] as [Any] { + let selection = try withBundle(value: value) { OpenIapProvider.select(bundle: $0) } + let module = OpenIapModule(selection: selection) + let subscription = module.purchaseUpdatedListener({ _ in }, options: nil) + module.removeListener(subscription) + module.removeAllListeners() + XCTAssertNil(module.storeId) + do { _ = try await module.initConnection(); XCTFail("Invalid provider must not connect") } + catch let error as PurchaseError { XCTAssertEqual(error.code, .developerError) } + do { + try await module.finishTransaction(purchase: purchase, isConsumable: false) + XCTFail("Finish on a failed selection must report the configuration cause") + } catch let error as PurchaseError { + XCTAssertEqual(error.code, .developerError) + XCTAssertTrue(error.message.contains("Info.plist"), "finish hid the cause: \(error.message)") + } + } + } + + func testSelectRejectsFactoryWithIncompatibleContract() async throws { + for factory in [DiscoveryIncompatibleCoreFactory(), DiscoveryIncompatibleClientProtocolFactory()] as [any OpenIapProviderFactory] { + do { + _ = try OpenIapProvider.select(factory: factory).get() + XCTFail("Incompatible factory must not be selected") + } catch let error as PurchaseError { XCTAssertEqual(error.code, .developerError) } + let module = OpenIapModule(factory: factory) + do { _ = try await module.initConnection(); XCTFail("Incompatible factory must not connect") } + catch let error as PurchaseError { XCTAssertEqual(error.code, .developerError) } + } + } + + func testSelectSurfacesFactoryCreateFailure() async throws { + let factory = DiscoveryThrowingCreateFactory() + do { + _ = try OpenIapProvider.select(factory: factory).get() + XCTFail("Factory that cannot create must not be selected") + } catch let error as PurchaseError { XCTAssertEqual(error.code, .developerError) } + let module = OpenIapModule(factory: factory) + do { _ = try await module.initConnection(); XCTFail("Factory that cannot create must not connect") } + catch let error as PurchaseError { XCTAssertEqual(error.code, .developerError) } + } + + func testInfoPlistSelectionOfFailingFactoryFailsOperations() async throws { + let purchase = try OpenIapSerialization.purchaseInput(from: [ + "store": "apple", "id": "txn", "transactionId": "txn", "productId": "sku", + "quantity": 1, "isAutoRenewing": false, "purchaseState": "purchased", "transactionDate": 1.0, + ]) + for name in ["DiscoveryIncompatibleCoreFactory", "DiscoveryThrowingCreateFactory"] { + let module = try withBundle(value: name) { OpenIapModule(selection: OpenIapProvider.select(bundle: $0)) } + XCTAssertNil(module.storeId) + do { _ = try await module.initConnection(); XCTFail("\(name) must not connect") } + catch let error as PurchaseError { XCTAssertEqual(error.code, .developerError) } + do { + try await module.finishTransaction(purchase: purchase, isConsumable: false) + XCTFail("\(name) must not finish") + } catch let error as PurchaseError { XCTAssertEqual(error.code, .developerError) } + } + } + + func testRejectedRequestDeliversOneErrorBeforeThrowing() async throws { + let module = OpenIapStoreKitModule() + let events = RequestEvents() + let errorListener = module.purchaseErrorListener { error in events.record(error.code) } + let purchaseListener = module.purchaseUpdatedListener({ _ in events.recordPurchase() }, options: nil) + defer { + module.removeListener(errorListener) + module.removeListener(purchaseListener) + } + let request = RequestPurchaseProps( + request: .purchase(RequestPurchasePropsByPlatforms()), + type: .inApp + ) + for count in 1...2 { + do { + _ = try await module.requestPurchase(request) + XCTFail("Missing Apple purchase arguments must fail") + } catch let error as PurchaseError { + XCTAssertEqual(error.code, .purchaseError) + XCTAssertEqual(events.errors, Array(repeating: .purchaseError, count: count)) + XCTAssertEqual(events.purchases, 0) + } + } + } + + func testCancelledStoreQueryCannotInitializeAConnection() async throws { + let module = OpenIapStoreKitModule() + let ready = expectation(description: "Query task ready") + let gate = QueryStartGate() + let query = Task { + await gate.wait(ready: ready) + return try await module.getActiveSubscriptions(nil) + } + await fulfillment(of: [ready], timeout: 1) + query.cancel() + await gate.release() + do { + _ = try await query.value + XCTFail("Cancelled queries must not connect to StoreKit") + } catch is CancellationError { + } + } + + func testCancelledRequestPurchaseDeliversNoPurchaseError() async throws { + let module = OpenIapStoreKitModule() + let events = RequestEvents() + let errorListener = module.purchaseErrorListener { error in events.record(error.code) } + let purchaseListener = module.purchaseUpdatedListener({ _ in events.recordPurchase() }, options: nil) + defer { + module.removeListener(errorListener) + module.removeListener(purchaseListener) + } + let request = RequestPurchaseProps( + request: .purchase(RequestPurchasePropsByPlatforms(apple: RequestPurchaseIosProps(sku: "cancelled.sku"))), + type: .inApp + ) + let ready = expectation(description: "Purchase task ready") + let gate = QueryStartGate() + let purchase = Task { + await gate.wait(ready: ready) + return try await module.requestPurchase(request) + } + await fulfillment(of: [ready], timeout: 1) + purchase.cancel() + await gate.release() + do { + _ = try await purchase.value + XCTFail("Cancelled purchases must reject") + } catch is CancellationError { + } catch { + XCTFail("Cancelled purchases must throw CancellationError, got \(error)") + } + XCTAssertEqual(events.errors, []) + XCTAssertEqual(events.purchases, 0) + } + + private func withBundle(value: Any?, operation: (Bundle) throws -> T) throws -> T { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".bundle") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: url) } + var info: [String: Any] = ["CFBundleIdentifier": "dev.hyo.provider-tests"] + info[OpenIapProvider.metadataKey] = value + let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try data.write(to: url.appendingPathComponent("Info.plist")) + return try operation(XCTUnwrap(Bundle(url: url))) + } +} + +@objc(DiscoveryIncompatibleCoreFactory) +private final class DiscoveryIncompatibleCoreFactory: NSObject, OpenIapProviderFactory { + required override init() { super.init() } + var storeId: String { "community-fixture" } + var coreVersion: String { "999.0.0" } + var clientProtocolVersion: String { OpenIapVersion.clientProtocolVersion } + func create() throws -> any OpenIapModuleProtocol { OpenIapStoreKitModule() } +} + +@objc(DiscoveryIncompatibleClientProtocolFactory) +private final class DiscoveryIncompatibleClientProtocolFactory: NSObject, OpenIapProviderFactory { + required override init() { super.init() } + var storeId: String { "community-fixture" } + var coreVersion: String { OpenIapVersion.current } + var clientProtocolVersion: String { "0.99.0" } + func create() throws -> any OpenIapModuleProtocol { OpenIapStoreKitModule() } +} + +@objc(DiscoveryThrowingCreateFactory) +private final class DiscoveryThrowingCreateFactory: NSObject, OpenIapProviderFactory { + required override init() { super.init() } + var storeId: String { "community-fixture" } + var coreVersion: String { OpenIapVersion.current } + var clientProtocolVersion: String { OpenIapVersion.clientProtocolVersion } + func create() throws -> any OpenIapModuleProtocol { + throw PurchaseError.make(code: .developerError, message: "Fixture factory cannot create a module") + } +} + +private final class RequestEvents: @unchecked Sendable { + private let lock = NSLock() + private var codes: [ErrorCode] = [] + private var purchaseCount = 0 + func record(_ code: ErrorCode) { lock.lock(); codes.append(code); lock.unlock() } + func recordPurchase() { lock.lock(); purchaseCount += 1; lock.unlock() } + var errors: [ErrorCode] { lock.lock(); defer { lock.unlock() }; return codes } + var purchases: Int { lock.lock(); defer { lock.unlock() }; return purchaseCount } +} + +private actor QueryStartGate { + private var continuation: CheckedContinuation? + func wait(ready: XCTestExpectation) async { + await withCheckedContinuation { + continuation = $0 + ready.fulfill() + } + } + func release() { + continuation?.resume() + continuation = nil + } +} diff --git a/packages/apple/Tests/OpenIapTests/RenewalInfoTests.swift b/packages/apple/Tests/OpenIapTests/RenewalInfoTests.swift index d95665a7e..74de047b9 100644 --- a/packages/apple/Tests/OpenIapTests/RenewalInfoTests.swift +++ b/packages/apple/Tests/OpenIapTests/RenewalInfoTests.swift @@ -207,6 +207,8 @@ final class RenewalInfoTests: XCTestCase { revocationReasonIOS: nil, store: .apple, storefrontCountryCodeIOS: "US", + + storeId: "apple", subscriptionGroupIdIOS: "21686373", transactionDate: 1729083955000, transactionId: "txn_123", @@ -272,6 +274,8 @@ final class RenewalInfoTests: XCTestCase { revocationReasonIOS: nil, store: .apple, storefrontCountryCodeIOS: "US", + + storeId: "apple", subscriptionGroupIdIOS: "21686373", transactionDate: 1729083955000, transactionId: "txn_456", diff --git a/packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift b/packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift index 20097d892..4cd63de36 100644 --- a/packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift +++ b/packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift @@ -114,6 +114,7 @@ final class StoreConformanceTests: XCTestCase { "purchaseState": "purchased", "quantity": 1, "store": "apple", + "storeId": "apple", "transactionDate": 1700000000000, "transactionId": "txn-1" } @@ -122,6 +123,29 @@ final class StoreConformanceTests: XCTestCase { XCTAssertNotEqual(purchase.store, .unknown, "a purchase must declare a concrete store") XCTAssertEqual(purchase.store, .apple) + XCTAssertEqual(purchase.storeId, "apple") + XCTAssertFalse(purchase.storeId.isEmpty, "storeId must never be empty") + + // A community provider reports Unknown with its own stable id. + let communityJson = """ + { + "id": "txn-community-1", + "productId": "dev.hyo.martie.premium", + "ids": ["dev.hyo.martie.premium"], + "isAutoRenewing": true, + "purchaseState": "purchased", + "quantity": 1, + "store": "unknown", + "storeId": "community-fixture", + "transactionDate": 1700000000000, + "transactionId": "txn-community-1" + } + """ + let community = try JSONDecoder().decode(PurchaseIOS.self, from: Data(communityJson.utf8)) + + XCTAssertEqual(community.store, .unknown) + XCTAssertEqual(community.storeId, "community-fixture") + XCTAssertFalse(community.storeId.isEmpty, "storeId must never be empty") } // verification.result-exposes-uniform-validity diff --git a/packages/apple/Tests/OpenIapTests/StoreIdentityTests.swift b/packages/apple/Tests/OpenIapTests/StoreIdentityTests.swift new file mode 100644 index 000000000..8238543bb --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/StoreIdentityTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import OpenIAP + +final class StoreIdentityTests: XCTestCase { + private func payload(_ store: String) -> [String: Any] { + ["store": store, "id": "txn", "transactionId": "txn", "productId": "sku", "quantity": 1, + "isAutoRenewing": false, "purchaseState": "purchased", "transactionDate": 1.0] + } + + func testLegacyOfficialIdentityAndPurchaseInput() throws { + for (store, id) in [("apple", "apple"), ("google", "play"), ("horizon", "horizon"), ("amazon", "amazon")] { + let purchase = try OpenIapSerialization.decode(object: payload(store), as: PurchaseIOS.self) + XCTAssertEqual(purchase.storeId, id) + XCTAssertEqual(OpenIapSerialization.encode(purchase)["storeId"] as? String, id) + } + for value in [payload("apple"), ["purchaseIos": ["_0": payload("apple")]]] { + let purchase = try OpenIapSerialization.purchaseInput(from: value) + guard case .purchaseIos(let ios) = purchase else { return XCTFail("Expected iOS purchase") } + XCTAssertEqual(ios.storeId, "apple") + } + } + + func testCommunityIdentityAndMalformedIdentity() throws { + var value = payload("unknown") + value["storeId"] = "community-fixture" + XCTAssertEqual(try OpenIapSerialization.decode(object: value, as: PurchaseIOS.self).storeId, "community-fixture") + for id in ["", "auto", "none", "unknown", "apple", "play", "google", "amazon", "horizon", "Bad id", "store\n"] { + value["storeId"] = id + XCTAssertThrowsError(try OpenIapSerialization.decode(object: value, as: PurchaseIOS.self)) + } + value.removeValue(forKey: "storeId") + XCTAssertThrowsError(try OpenIapSerialization.decode(object: value, as: PurchaseIOS.self)) + value["store"] = "apple" + value["storeId"] = "other" + XCTAssertThrowsError(try OpenIapSerialization.decode(object: value, as: PurchaseIOS.self)) + } +} diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift index 76e108de2..e7d320673 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift @@ -253,7 +253,7 @@ final class VerifyPurchaseTests: XCTestCase { let currentChanged = makePurchase(id: "changed", isAutoRenewing: true) let added = makePurchase(id: "added") - let result = OpenIapModule.changedPurchasesIOS( + let result = OpenIapStoreKitModule.changedPurchasesIOS( [unchanged, currentChanged, added], comparedTo: [unchanged, previousChanged] ) @@ -278,6 +278,7 @@ final class VerifyPurchaseTests: XCTestCase { purchaseState: .purchased, quantity: 1, store: .apple, + storeId: "apple", transactionDate: 1, transactionId: id ) diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index 2d7673754..3af06b585 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -7,12 +7,12 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { func testIapkitVerificationURLUsesHostedDefaultForNilOrBlankBaseUrl() throws { let expectedUrl = "https://kit.openiap.dev/v1/purchase/verify" - XCTAssertEqual(expectedUrl, try OpenIapModule.iapkitVerificationURL(baseUrl: nil).absoluteString) - XCTAssertEqual(expectedUrl, try OpenIapModule.iapkitVerificationURL(baseUrl: " \n\t ").absoluteString) + XCTAssertEqual(expectedUrl, try OpenIapStoreKitModule.iapkitVerificationURL(baseUrl: nil).absoluteString) + XCTAssertEqual(expectedUrl, try OpenIapStoreKitModule.iapkitVerificationURL(baseUrl: " \n\t ").absoluteString) } func testIapkitVerificationURLTrimsOverrideAndTrailingSlashes() throws { - let url = try OpenIapModule.iapkitVerificationURL( + let url = try OpenIapStoreKitModule.iapkitVerificationURL( baseUrl: " http://127.0.0.1:4174///\n" ) @@ -33,7 +33,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { for invalidBaseUrl in invalidBaseUrls { XCTAssertThrowsError( - try OpenIapModule.iapkitVerificationURL(baseUrl: invalidBaseUrl), + try OpenIapStoreKitModule.iapkitVerificationURL(baseUrl: invalidBaseUrl), "Expected \(invalidBaseUrl) to be rejected" ) { error in guard let purchaseError = error as? PurchaseError else { @@ -66,7 +66,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { func testIapkitClientPayloadParsesAndDropsUnreadableValues() throws { let payload = try XCTUnwrap( - OpenIapModule.iapkitClientPayload(from: [ + OpenIapStoreKitModule.iapkitClientPayload(from: [ "format": "toml", "body": "tier = \"gold\"", "version": 2, @@ -77,11 +77,11 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertEqual(.toml, payload.format) XCTAssertEqual("tier = \"gold\"", payload.body) XCTAssertEqual(2, payload.version) - XCTAssertNil(OpenIapModule.iapkitClientPayload(from: nil)) - XCTAssertNil(OpenIapModule.iapkitClientPayload(from: NSNull())) + XCTAssertNil(OpenIapStoreKitModule.iapkitClientPayload(from: nil)) + XCTAssertNil(OpenIapStoreKitModule.iapkitClientPayload(from: NSNull())) // A later format, and every structural defect, drop the payload. XCTAssertNil( - OpenIapModule.iapkitClientPayload(from: [ + OpenIapStoreKitModule.iapkitClientPayload(from: [ "format": "yaml", "body": "tier: gold", "version": 1, @@ -89,7 +89,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { ]) ) XCTAssertNil( - OpenIapModule.iapkitClientPayload(from: [ + OpenIapStoreKitModule.iapkitClientPayload(from: [ "format": "TOML", "body": "invalid format", "version": 1, @@ -97,7 +97,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { ]) ) XCTAssertNil( - OpenIapModule.iapkitClientPayload(from: [ + OpenIapStoreKitModule.iapkitClientPayload(from: [ "format": "toml", "body": "missing version", "updatedAt": 1, @@ -105,7 +105,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { ) for invalidVersion: Any in [true, 0, 1.5] { XCTAssertNil( - OpenIapModule.iapkitClientPayload(from: [ + OpenIapStoreKitModule.iapkitClientPayload(from: [ "format": "toml", "body": "invalid version", "version": invalidVersion, @@ -114,7 +114,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { ) } XCTAssertNil( - OpenIapModule.iapkitClientPayload(from: [ + OpenIapStoreKitModule.iapkitClientPayload(from: [ "format": "toml", "body": "invalid timestamp", "version": 1, @@ -124,36 +124,36 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { } func testIapkitBooleanRejectsNumericJsonValues() throws { - XCTAssertTrue(try OpenIapModule.iapkitBoolean(from: true)) - XCTAssertFalse(try OpenIapModule.iapkitBoolean(from: false)) + XCTAssertTrue(try OpenIapStoreKitModule.iapkitBoolean(from: true)) + XCTAssertFalse(try OpenIapStoreKitModule.iapkitBoolean(from: false)) for invalidValue: Any? in [nil, NSNull(), 0, 1, "true"] { XCTAssertThrowsError( - try OpenIapModule.iapkitBoolean(from: invalidValue) + try OpenIapStoreKitModule.iapkitBoolean(from: invalidValue) ) } } func testIapkitEnvironmentForwardsAnyStringAndNeverFails() throws { - XCTAssertNil(OpenIapModule.iapkitEnvironment(from: nil)) - XCTAssertNil(OpenIapModule.iapkitEnvironment(from: NSNull())) - XCTAssertEqual("Sandbox", OpenIapModule.iapkitEnvironment(from: "Sandbox")) - XCTAssertEqual("Production", OpenIapModule.iapkitEnvironment(from: "Production")) + XCTAssertNil(OpenIapStoreKitModule.iapkitEnvironment(from: nil)) + XCTAssertNil(OpenIapStoreKitModule.iapkitEnvironment(from: NSNull())) + XCTAssertEqual("Sandbox", OpenIapStoreKitModule.iapkitEnvironment(from: "Sandbox")) + XCTAssertEqual("Production", OpenIapStoreKitModule.iapkitEnvironment(from: "Production")) // "Xcode" and "LocalTesting" are real App Store Server environments. for forwarded in ["sandbox", "Xcode", "LocalTesting", "Staging"] { - XCTAssertEqual(forwarded, OpenIapModule.iapkitEnvironment(from: forwarded)) + XCTAssertEqual(forwarded, OpenIapStoreKitModule.iapkitEnvironment(from: forwarded)) } // Only a non-string, or an empty string, has nothing to forward. for unreadableValue: Any in ["", 1, true, [:], []] { - XCTAssertNil(OpenIapModule.iapkitEnvironment(from: unreadableValue)) + XCTAssertNil(OpenIapStoreKitModule.iapkitEnvironment(from: unreadableValue)) } } func testIapkitRequestCarriesNoProtocolVersionHeader() throws { let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) - let request = OpenIapModule.makeIapkitRequest( + let request = OpenIapStoreKitModule.makeIapkitRequest( url: url, apiKey: " iapkit_pk_test ", body: Data("{}".utf8) @@ -191,7 +191,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) for blank in [nil, "", " "] as [String?] { - let request = OpenIapModule.makeIapkitRequest( + let request = OpenIapStoreKitModule.makeIapkitRequest( url: url, apiKey: blank, body: Data() @@ -201,7 +201,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { } func testIapkitAmazonPayloadForwardsExpectedProductId() throws { - let payload = try OpenIapModule.iapkitAmazonPayload( + let payload = try OpenIapStoreKitModule.iapkitAmazonPayload( from: RequestVerifyPurchaseWithIapkitAmazonProps( expectedProductId: "premium.monthly", receiptId: " amzn1.receipt.ABC123456789 ", @@ -226,7 +226,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { } func testIapkitHorizonPayloadForwardsUserAndSku() throws { - let payload = try OpenIapModule.iapkitHorizonPayload( + let payload = try OpenIapStoreKitModule.iapkitHorizonPayload( from: RequestVerifyPurchaseWithIapkitHorizonProps( sku: " premium.monthly ", userId: " 123456789 " @@ -252,7 +252,8 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { isValid: true, productId: "premium.monthly", state: .entitled, - store: .apple + store: .apple, + storeId: "apple" ) let module = FakeVerifyPurchaseModule( validateResult: VerifyPurchaseResult.verifyPurchaseResultIos( diff --git a/packages/apple/compatibility/community-provider/Package.swift b/packages/apple/compatibility/community-provider/Package.swift new file mode 100644 index 000000000..29e9ba7b8 --- /dev/null +++ b/packages/apple/compatibility/community-provider/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 5.9 +import PackageDescription +let package = Package( + name: "FixtureProvider", + platforms: [.iOS(.v15), .macOS(.v14)], + products: [.library(name: "FixtureProvider", targets: ["FixtureProvider"])], + dependencies: [.package(name: "OpenIAP", path: "../../../..")], + targets: [ + .target(name: "FixtureProvider", dependencies: [.product(name: "OpenIAP", package: "OpenIAP")], plugins: ["FixtureBuildMetadata"]), + .executableTarget(name: "FixtureBuildMetadataGenerator"), + .plugin(name: "FixtureBuildMetadata", capability: .buildTool(), dependencies: ["FixtureBuildMetadataGenerator"]), + .testTarget(name: "FixtureProviderTests", dependencies: ["FixtureProvider", .product(name: "OpenIapConformance", package: "OpenIAP")]), + ] +) diff --git a/packages/apple/compatibility/community-provider/Plugins/FixtureBuildMetadata/plugin.swift b/packages/apple/compatibility/community-provider/Plugins/FixtureBuildMetadata/plugin.swift new file mode 100644 index 000000000..e59019ba6 --- /dev/null +++ b/packages/apple/compatibility/community-provider/Plugins/FixtureBuildMetadata/plugin.swift @@ -0,0 +1,15 @@ +import PackagePlugin + +@main +struct FixtureBuildMetadata: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] { + let versions = context.package.directory.appending("../../../../openiap-versions.json") + let output = context.pluginWorkDirectory.appending("FixtureBuildVersion.swift") + return [.buildCommand( + displayName: "Freeze provider build versions", + executable: try context.tool(named: "FixtureBuildMetadataGenerator").path, + arguments: [versions.string, output.string], + inputFiles: [versions], outputFiles: [output] + )] + } +} diff --git a/packages/apple/compatibility/community-provider/README.md b/packages/apple/compatibility/community-provider/README.md new file mode 100644 index 000000000..f8cf8a7c7 --- /dev/null +++ b/packages/apple/compatibility/community-provider/README.md @@ -0,0 +1,29 @@ +# Independent Apple provider fixture + +This in-memory store imports only the public `OpenIAP` product. It preserves +opaque transaction ids, receipts and `storeId` through the facade and full +Objective-C completion selector. It has no store billing SDK or real purchases. +The build plugin freezes the native and Client Protocol versions from the +fixture's pinned dependency metadata into its binary. + +Run from the repository root: + +```bash +OPENIAP_PROVIDER_REPORT=/tmp/apple-provider-report.json \ + swift test --package-path packages/apple/compatibility/community-provider +swift run --package-path packages/apple/compatibility/community-provider/consumer \ + -c release FixtureConsumer +``` + +The tests consume the public `OpenIapConformance` product. They check every +declared behavior, missing-event rejection, deferred-payment errors, billing +grace entitlement, listener removal, canonical restore and redemption dispatch, +and full completion payloads. The Release consumer links with `-ObjC` and +`-dead_strip`, discovers `CommunityFixtureFactory` through Info.plist metadata, +and completes a purchase without directly referencing the factory class. + +For a real provider, declare the versions used to build the binary, use the +store's production mappers and sandbox triggers, and upload the passing JSON +report from CI. Follow the [common provider guide](https://openiap.dev/docs/guides/store-providers) +for app selection and optional registration. No artifact is published by these +commands. diff --git a/packages/apple/compatibility/community-provider/Sources/FixtureBuildMetadataGenerator/main.swift b/packages/apple/compatibility/community-provider/Sources/FixtureBuildMetadataGenerator/main.swift new file mode 100644 index 000000000..251b7a236 --- /dev/null +++ b/packages/apple/compatibility/community-provider/Sources/FixtureBuildMetadataGenerator/main.swift @@ -0,0 +1,15 @@ +import Foundation + +let args = CommandLine.arguments +let data = try Data(contentsOf: URL(fileURLWithPath: args[1])) +let versions = try JSONDecoder().decode([String: String].self, from: data) +guard let core = versions["apple"], let client = versions["clientProtocol"], + [core, client].allSatisfy({ $0.range(of: #"^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$"#, options: .regularExpression) != nil }) else { + fatalError("Provider build requires complete native and Client Protocol versions") +} +try """ +enum FixtureBuildVersion { + static let core = "\(core)" + static let clientProtocol = "\(client)" +} +""".write(toFile: args[2], atomically: true, encoding: .utf8) diff --git a/packages/apple/compatibility/community-provider/Sources/FixtureProvider/FixtureProvider.swift b/packages/apple/compatibility/community-provider/Sources/FixtureProvider/FixtureProvider.swift new file mode 100644 index 000000000..20cce59f3 --- /dev/null +++ b/packages/apple/compatibility/community-provider/Sources/FixtureProvider/FixtureProvider.swift @@ -0,0 +1,205 @@ +import Foundation +import OpenIAP + +@objc(CommunityFixtureFactory) +public final class FixtureFactory: NSObject, OpenIapProviderFactory { + public required override init() { super.init() } + public var storeId: String { "community-fixture" } + public var coreVersion: String { FixtureBuildVersion.core } + public var clientProtocolVersion: String { FixtureBuildVersion.clientProtocol } + public var capabilities: Set { ["pendingPurchases", "subscriptionBillingIssue", "offerCodeRedemption"] } + public func create() throws -> any OpenIapModuleProtocol { FixtureModule() } +} + +public final class FixtureModule: OpenIapModuleProtocol, @unchecked Sendable { + private let lock = NSRecursiveLock() + private var connected = false + private var updated: [UUID: PurchaseUpdatedListener] = [:] + private var billing: [UUID: SubscriptionBillingIssueListener] = [:] + private var errors: [UUID: PurchaseErrorListener] = [:] + private var owned: [String: Purchase] = [:] + public private(set) var finished: [String: Purchase] = [:] + public var rejectOwnedPurchase = false + public var failureEventCount = 1 + public var returnFailureInsteadOfThrow = false + public var genericRequestFailure: Error? + public var purchaseOnFailure = false + public var rotateTokenAfterFirstRead = false + private var ownedReadCount = 0 + public var emitAndroidPurchase = false + public private(set) var lastRequest: RequestPurchaseProps? + public var connectionResult = true + public var connectionError: PurchaseError? + public private(set) var disconnectCount = 0 + public var isConnected: Bool { synchronized { connected } } + public var deferredPending = false + public var billingGracePeriod = false + public var deferredThenPurchased = false + public var capabilityStore: IapStore = .unknown + public var inactiveSubscriptions = false + public var forcedHasActive: Bool? + public private(set) var lastSubscriptionIds: [String]? + public init() {} + private func synchronized(_ work: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try work() + } + public func initConnection() async throws -> Bool { + synchronized { connected = true } + if let connectionError { throw connectionError } + return connectionResult + } + public func endConnection() async throws -> Bool { + synchronized { connected = false; disconnectCount += 1 } + return true + } + public func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult { + .products(params.skus.map { sku in + .productIos(ProductIOS( + currency: "USD", description: "Independent provider fixture", displayNameIOS: "Fixture", + displayPrice: "$1.00", id: sku, isFamilyShareableIOS: false, + jsonRepresentationIOS: "{}", price: 1.0, title: "Fixture", typeIOS: .consumable + )) + }) + } + + public func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { + let sku: String? + switch params.request { + case .purchase(let props): sku = props.apple?.sku + case .subscription(let props): sku = props.apple?.sku + } + guard synchronized({ connected }) else { try failRequest(PurchaseError.make(code: .notPrepared)) } + guard let sku, !sku.isEmpty else { + let error = normalizeError("missing-sku") + if returnFailureInsteadOfThrow { + try emitRequestFailure(error) + return .purchases([]) + } + try failRequest(error) + } + if rejectOwnedPurchase && synchronized({ owned.values.contains { $0.productId == sku } }) { + try failRequest(PurchaseError.make(code: .alreadyOwned)) + } + let ios = try makePurchase(sku: sku) + let purchase = emitAndroidPurchase ? Purchase.purchaseAndroid(PurchaseAndroid( + id: ios.id, isAutoRenewing: ios.isAutoRenewing, productId: ios.productId, + purchaseState: ios.purchaseState, purchaseToken: ios.purchaseToken, quantity: ios.quantity, + store: ios.store, storeId: ios.storeId, transactionDate: ios.transactionDate + )) : Purchase.purchaseIos(ios) + let listeners = synchronized { + lastRequest = params + owned[purchase.id] = purchase + return Array(updated.values) + } + listeners.forEach { $0(purchase) } + return .purchase(purchase) + } + public func restorePurchases() async throws {} + public func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] { + synchronized { + ownedReadCount += 1 + return owned.values.map { purchase in + guard rotateTokenAfterFirstRead, ownedReadCount > 1, + case .purchaseIos(var ios) = purchase else { return purchase } + ios.purchaseToken = "altered-token" + return .purchaseIos(ios) + } + } + } + private func failRequest(_ error: PurchaseError) throws -> Never { + try emitRequestFailure(error) + throw genericRequestFailure ?? error + } + private func emitRequestFailure(_ error: PurchaseError) throws { + let listeners = synchronized { Array(errors.values) } + for _ in 0.. [ActiveSubscription] { + try await getAvailablePurchases(nil).compactMap { purchase in + guard case .purchaseIos(let ios) = purchase, + (ios.isAutoRenewing || inactiveSubscriptions), ios.purchaseState == .purchased, + subscriptionIds?.contains(ios.productId) != false else { return nil } + return toActiveSubscription(ios) + } + } + public func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool { + lastSubscriptionIds = subscriptionIds + if let forcedHasActive { return forcedHasActive } + return try await !getActiveSubscriptions(subscriptionIds).isEmpty + } + public func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { + .verifyPurchaseResultIos(try OpenIapSerialization.decode(object: ["isValid": true], as: VerifyPurchaseResultIOS.self)) + } + public func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { + try OpenIapSerialization.decode(object: [ + "provider": "iapkit", "iapkit": ["store": "unknown", "storeId": "community-fixture", "isValid": true, "state": "entitled"], + ], as: VerifyPurchaseWithProviderResult.self) + } + public func getStorefront() async throws -> String { "US" } + public func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws {} + public func openRedeemOfferCode() async throws -> PurchaseIOS? { nil } + public func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener, options: PurchaseUpdatedListenerOptions?) -> Subscription { + let token = Subscription(eventType: .purchaseUpdated) + synchronized { updated[token.id] = listener } + return token + } + public func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription { + let token = Subscription(eventType: .purchaseError) + synchronized { errors[token.id] = listener } + return token + } + public func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription { + let token = Subscription(eventType: .subscriptionBillingIssue) + synchronized { billing[token.id] = listener } + return token + } + public func removeListener(_ subscription: Subscription) { + synchronized { updated.removeValue(forKey: subscription.id); billing.removeValue(forKey: subscription.id); errors.removeValue(forKey: subscription.id) } + } + public func removeAllListeners() { synchronized { updated.removeAll(); billing.removeAll(); errors.removeAll() } } + public func trigger(_ capability: String) throws { + if capability == "pendingPurchases" && deferredThenPurchased { + let listeners = synchronized { Array(errors.values) } + listeners.forEach { $0(PurchaseError.make(code: .deferredPayment)) } + } + if capability == "pendingPurchases" && deferredPending { + throw PurchaseError.make(code: .deferredPayment) + } + let pending = capability != "offerCodeRedemption" && !(capability == "subscriptionBillingIssue" && billingGracePeriod) + var payload = try makePurchase(sku: "conformance.capability", state: pending && !deferredThenPurchased ? .pending : .purchased) + payload.store = capabilityStore + let purchase = Purchase.purchaseIos(payload) + let listeners = synchronized { capability == "subscriptionBillingIssue" ? Array(billing.values) : Array(updated.values) } + listeners.forEach { $0(purchase) } + } + public func makePurchase(sku: String = "conformance.product", state: PurchaseState = .purchased) throws -> PurchaseIOS { + try OpenIapSerialization.decode(object: [ + "id": "txn-\(sku)", "transactionId": "txn-\(sku)", "productId": sku, + "store": "unknown", "storeId": "community-fixture", "quantity": 1, "isAutoRenewing": false, + "purchaseState": state.rawValue, "purchaseToken": "fixture-token-\(sku)", "transactionDate": 1.0, + ], as: PurchaseIOS.self) + } + public func toActiveSubscription(_ purchase: PurchaseIOS) -> ActiveSubscription { + ActiveSubscription( + currentPlanId: purchase.productId, isActive: purchase.purchaseState == .purchased && !inactiveSubscriptions, + productId: purchase.productId, purchaseToken: purchase.purchaseToken, + transactionDate: purchase.transactionDate, transactionId: purchase.id + ) + } + + public func normalizeError(_ code: String) -> PurchaseError { + .make(code: ["cancelled": ErrorCode.userCancelled, "missing-sku": .skuNotFound][code] ?? .unknown) + } +} diff --git a/packages/apple/compatibility/community-provider/Tests/FixtureProviderTests/FixtureProviderTests.swift b/packages/apple/compatibility/community-provider/Tests/FixtureProviderTests/FixtureProviderTests.swift new file mode 100644 index 000000000..b5ed6441f --- /dev/null +++ b/packages/apple/compatibility/community-provider/Tests/FixtureProviderTests/FixtureProviderTests.swift @@ -0,0 +1,338 @@ +import Foundation +import XCTest +import OpenIAP +import OpenIapConformance +import FixtureProvider + +private struct Adapter: ProviderConformanceAdapter { + var factory: any OpenIapProviderFactory = FixtureFactory() + let module: FixtureModule + var omitCapability: String? + var provider: any OpenIapModuleProtocol { module } + var normativeErrorCases: [ProviderErrorCase] { + [ProviderErrorCase(expected: .userCancelled, actual: module.normalizeError("cancelled")), + ProviderErrorCase(expected: .skuNotFound, actual: module.normalizeError("missing-sku"))] + } + var unrecognizedError: PurchaseError { module.normalizeError("unknown-native-code") } + func toActiveSubscription(_ purchase: PurchaseIOS) -> ActiveSubscription { module.toActiveSubscription(purchase) } + func billingIssueRetainsEntitlement(_ purchase: PurchaseIOS) -> Bool { module.billingGracePeriod } + func triggerCapability(_ capability: String) async throws { + if capability != omitCapability { try module.trigger(capability) } + } +} + +final class FixtureProviderTests: XCTestCase { + func testPublicConformancePassesEveryDeclaredBehavior() async throws { + let report = await ProviderConformanceSuite(adapter: Adapter(module: FixtureModule())).run() + XCTAssertTrue(report.conformant) + XCTAssertTrue(report.scope.complete) + XCTAssertEqual(report.scope.requiredBehaviors.count, 18) + XCTAssertEqual(report.clientProtocolVersion, OpenIapVersion.clientProtocolVersion) + if let path = ProcessInfo.processInfo.environment["OPENIAP_PROVIDER_REPORT"] { + try report.write(to: URL(fileURLWithPath: path)) + } + } + + func testConformanceReusesAnOwnedPurchase() async { + let provider = FixtureModule() + provider.rejectOwnedPurchase = true + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider)).run() + XCTAssertTrue(report.conformant) + XCTAssertTrue(report.scope.complete) + } + + func testInvalidRequestRequiresExactlyOneFailureEventAndNoPurchase() async { + for mode in ["missing", "duplicate", "purchase"] { + let provider = FixtureModule() + provider.failureEventCount = mode == "missing" ? 0 : mode == "duplicate" ? 2 : 1 + provider.purchaseOnFailure = mode == "purchase" + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider), eventTimeout: 0.05).run() + XCTAssertFalse(report.conformant, mode) + XCTAssertEqual(report.results.first { $0.id == "provider.invalid-purchase-emits-error-once" }?.outcome, "fail", mode) + } + } + + func testRepeatedReadTokensMustMatchThePurchaseCallback() async { + let provider = FixtureModule() + provider.rotateTokenAfterFirstRead = true + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider), eventTimeout: 0.05).run() + XCTAssertEqual(report.results.first { $0.id == "restoration.available-purchases-returns-owned-items" }?.outcome, "pass") + XCTAssertEqual(report.results.first { $0.id == "identifiers.purchase-token-is-stable-across-reads" }?.outcome, "fail") + XCTAssertFalse(report.conformant) + } + + func testFailureEventMayAccompanyAnEmptyResult() async { + let provider = FixtureModule() + provider.returnFailureInsteadOfThrow = true + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider), eventTimeout: 0.05).run() + XCTAssertTrue(report.conformant) + XCTAssertEqual(report.results.first { $0.id == "provider.invalid-purchase-emits-error-once" }?.outcome, "pass") + } + + func testGenericThrowStillRequiresExactlyOneCanonicalFailureEvent() async { + for count in [0, 1, 2] { + let provider = FixtureModule() + provider.genericRequestFailure = NSError(domain: "CommunityProvider", code: 1) + provider.failureEventCount = count + let report = await ProviderConformanceSuite( + adapter: Adapter(module: provider), eventTimeout: 0.05 + ).run() + XCTAssertEqual(report.conformant, count == 1) + XCTAssertEqual( + report.results.first { $0.id == "provider.invalid-purchase-emits-error-once" }?.outcome, + count == 1 ? "pass" : "fail" + ) + } + } + + func testConformanceRejectsAndroidPurchasesOnApple() async { + let provider = FixtureModule() + provider.emitAndroidPurchase = true + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider)).run() + XCTAssertFalse(report.conformant) + XCTAssertEqual(report.results.first { $0.id == "purchases.request-emits-purchase-updated-on-success" }?.outcome, "fail") + } + + func testObjCRequestJSONPreservesBooleanOptionsAndDictionaryCompatibility() async throws { + for useJSON in [true, false] { + let provider = FixtureModule() + let module = OpenIapModule(factory: FixedFactory(provider)) + _ = try await module.initConnection() + let payload: [String: Any] = ["type": "in-app", "requestPurchase": ["apple": [ + "sku": "conformance.product", "andDangerouslyFinishTransactionAutomatically": false + ]]] + let completed = expectation(description: "Objective-C request") + let completion: (Any?, Error?) -> Void = { result, error in + XCTAssertNil(error) + XCTAssertNotNil(result) + completed.fulfill() + } + if useJSON { + let data = try JSONSerialization.data(withJSONObject: payload) + module.requestPurchaseWithJSON(String(decoding: data, as: UTF8.self), completion: completion) + } else { module.requestPurchaseWithPayload(payload, completion: completion) } + await fulfillment(of: [completed], timeout: 2) + guard case .purchase(let request) = provider.lastRequest?.request else { + XCTFail("Provider did not receive a purchase request") + continue + } + XCTAssertEqual(request.apple?.andDangerouslyFinishTransactionAutomatically, false) + } + } + + func testFailedConnectionAttemptsDisconnect() async { + for throwsError in [false, true] { + let provider = FixtureModule() + provider.connectionResult = false + if throwsError { provider.connectionError = PurchaseError.make(code: .initConnection) } + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider)).run() + XCTAssertFalse(report.conformant) + XCTAssertFalse(report.scope.complete) + XCTAssertEqual(report.results.first { $0.id == "provider.connection" }?.outcome, "fail") + XCTAssertEqual(report.results.first { $0.id == "provider.disconnect" }?.outcome, "pass") + XCTAssertEqual(provider.disconnectCount, 1) + XCTAssertFalse(provider.isConnected) + } + } + + func testDeferredErrorsAndBillingGraceRetainTheirPlatformSemantics() async { + let provider = FixtureModule() + provider.deferredPending = true + provider.billingGracePeriod = true + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider)).run() + XCTAssertTrue(report.conformant) + } + + func testDeclaredCapabilityCannotPassWithoutItsEvent() async { + let report = await ProviderConformanceSuite( + adapter: Adapter(module: FixtureModule(), omitCapability: "subscriptionBillingIssue"), eventTimeout: 0.05 + ).run() + XCTAssertFalse(report.conformant) + XCTAssertTrue(report.scope.complete) + XCTAssertEqual(report.results.first { $0.id == "apple-provider.subscription-billing-issue" }?.outcome, "fail") + } + + func testPendingRejectsPurchasedAfterDeferredError() async { + let provider = FixtureModule() + provider.deferredThenPurchased = true + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider), eventTimeout: 0.05).run() + XCTAssertFalse(report.conformant) + XCTAssertEqual(report.results.first { $0.id == ProviderBehaviors.capabilities["pendingPurchases"] }?.outcome, "fail") + } + + func testCapabilityEventsRequireConsistentStoreIdentity() async { + let provider = FixtureModule() + provider.capabilityStore = .apple + let report = await ProviderConformanceSuite(adapter: Adapter(module: provider), eventTimeout: 0.05).run() + XCTAssertFalse(report.conformant) + XCTAssertEqual(report.results.filter { $0.outcome == "fail" }.count, 3) + } + + func testFilteredBooleanUsesProviderResultInsteadOfListLength() async throws { + let provider = FixtureModule() + provider.inactiveSubscriptions = true + provider.forcedHasActive = false + let module = OpenIapModule(factory: FixedFactory(provider)) + _ = try await module.initConnection() + let request = try OpenIapSerialization.decode(object: ["type": "in-app", "requestPurchase": ["apple": ["sku": "conformance.product"]]], as: RequestPurchaseProps.self) + _ = try await module.requestPurchase(request) + let subscriptions = try await module.getActiveSubscriptions(["conformance.product"]) + XCTAssertEqual(subscriptions.count, 1) + XCTAssertFalse(try XCTUnwrap(subscriptions.first).isActive) + let completed = expectation(description: "Filtered native boolean") + module.hasActiveSubscriptionsWithSubscriptionIds(["conformance.product"]) { active, error in + XCTAssertNil(error) + XCTAssertFalse(active) + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 2) + XCTAssertEqual(provider.lastSubscriptionIds, ["conformance.product"]) + } + + func testFacadePreservesOpaqueIdentityAndFullCompletion() async throws { + let provider = FixtureModule() + let module = OpenIapModule(factory: FixedFactory(provider)) + let connected = try await module.initConnection() + XCTAssertTrue(connected) + let request = try OpenIapSerialization.decode(object: ["type": "in-app", "requestPurchase": ["apple": ["sku": "conformance.product"]]], as: RequestPurchaseProps.self) + _ = try await module.requestPurchase(request) + try await module.restorePurchases() + _ = try await module.presentCodeRedemptionSheetIOS() + let owned = try await module.getAvailablePurchases(nil) + let purchase = try XCTUnwrap(owned.first) + XCTAssertEqual(purchase.storeId, "community-fixture") + XCTAssertEqual(purchase.store, .unknown) + let expectation = expectation(description: "Objective-C completion") + let data = try JSONEncoder().encode(purchase) + // The public union and flattened payload paths must both preserve identity. + module.finishTransactionWithPurchaseJSON(String(decoding: data, as: UTF8.self), isConsumable: false) { error in + XCTAssertNil(error) + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 2) + XCTAssertEqual(provider.finished[purchase.id]?.purchaseToken, purchase.purchaseToken) + let legacy = self.expectation(description: "Legacy completion") + module.finishTransactionWithPurchaseId(purchase.id, productId: purchase.productId, isConsumable: false) { error in + XCTAssertNil(error) + legacy.fulfill() + } + await fulfillment(of: [legacy], timeout: 2) + do { _ = try await module.getReceiptDataIOS(); XCTFail("Optional StoreKit operation must be unsupported") } + catch let error as PurchaseError { XCTAssertEqual(error.code, .featureNotSupported) } + let disconnected = try await module.endConnection() + XCTAssertTrue(disconnected) + } + + func testUnknownDeclaredCapabilityFailsConformance() async { + let report = await ProviderConformanceSuite( + adapter: Adapter(factory: UnknownCapabilityFactory(), module: FixtureModule()), eventTimeout: 0.05 + ).run() + XCTAssertFalse(report.conformant) + XCTAssertTrue(report.capabilities.contains("mysteryCapability")) + XCTAssertEqual(report.results.first { $0.id == "provider.unknown-capability.mysteryCapability" }?.outcome, "fail") + XCTAssertEqual(report.results.first { $0.id == "capabilities.declared-capabilities-match-the-matrix" }?.outcome, "fail") + } + + func testUnknownDeclaredCapabilityFailsEvenWhenConnectionFails() async { + let provider = FixtureModule() + provider.connectionResult = false + let report = await ProviderConformanceSuite( + adapter: Adapter(factory: UnknownCapabilityFactory(), module: provider), eventTimeout: 0.05 + ).run() + XCTAssertFalse(report.conformant) + XCTAssertEqual(report.results.first { $0.id == "provider.unknown-capability.mysteryCapability" }?.outcome, "fail") + XCTAssertEqual(report.results.first { $0.id == "provider.connection" }?.outcome, "fail") + } + + func testLegacyCompletionFinishesAnUnfinishedPurchase() async throws { + let provider = FixtureModule() + let module = OpenIapModule(factory: FixedFactory(provider)) + _ = try await module.initConnection() + let request = try OpenIapSerialization.decode(object: ["type": "in-app", "requestPurchase": ["apple": ["sku": "conformance.second"]]], as: RequestPurchaseProps.self) + _ = try await module.requestPurchase(request) + let owned = try await module.getAvailablePurchases(nil) + let purchase = try XCTUnwrap(owned.first { $0.productId == "conformance.second" }) + XCTAssertNil(provider.finished[purchase.id]) + let completed = expectation(description: "Legacy completion finishes") + module.finishTransactionWithPurchaseId(purchase.id, productId: purchase.productId, isConsumable: false) { error in + XCTAssertNil(error) + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 2) + XCTAssertEqual(provider.finished[purchase.id]?.purchaseToken, purchase.purchaseToken) + } + + func testFinishRejectsPurchaseFromAnotherProvider() async throws { + let provider = FixtureModule() + let module = OpenIapModule(factory: FixedFactory(provider)) + _ = try await module.initConnection() + let foreign = try OpenIapSerialization.decode(object: [ + "id": "txn-other", "transactionId": "txn-other", "productId": "conformance.product", + "store": "apple", "storeId": "apple", "quantity": 1, "isAutoRenewing": false, + "purchaseState": "purchased", "purchaseToken": "token-other", "transactionDate": 1.0, + ], as: PurchaseIOS.self) + do { + try await module.finishTransaction(purchase: .purchaseIos(foreign), isConsumable: false) + XCTFail("A purchase from another provider must not finish") + } catch let error as PurchaseError { + XCTAssertEqual(error.code, .developerError) + XCTAssertTrue(error.message.contains("differs"), "wrong guard: \(error.message)") + } + XCTAssertTrue(provider.finished.isEmpty) + } + + func testFinishRejectsNonIOSPurchase() async throws { + let provider = FixtureModule() + let module = OpenIapModule(factory: FixedFactory(provider)) + _ = try await module.initConnection() + let android = Purchase.purchaseAndroid(PurchaseAndroid( + id: "txn-android", isAutoRenewing: false, productId: "conformance.product", + purchaseState: .purchased, purchaseToken: "fixture-token-android", quantity: 1, + store: .unknown, storeId: "community-fixture", transactionDate: 1.0 + )) + do { + try await module.finishTransaction(purchase: android, isConsumable: false) + XCTFail("A non-iOS purchase must not finish") + } catch let error as PurchaseError { + XCTAssertEqual(error.code, .developerError) + XCTAssertTrue(error.message.contains("iOS purchase"), "wrong guard: \(error.message)") + } + XCTAssertTrue(provider.finished.isEmpty) + } + + func testRemovingListenersStopsProviderEvents() async throws { + let provider = FixtureModule() + let count = Count() + let subscription = provider.purchaseUpdatedListener({ _ in count.increment() }, options: nil) + try provider.trigger("pendingPurchases") + XCTAssertEqual(count.value, 1) + provider.removeListener(subscription) + try provider.trigger("pendingPurchases") + XCTAssertEqual(count.value, 1) + } +} + +private final class Count: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + func increment() { lock.lock(); count += 1; lock.unlock() } + var value: Int { lock.lock(); defer { lock.unlock() }; return count } +} +private final class UnknownCapabilityFactory: OpenIapProviderFactory { + private let base = FixtureFactory() + required init() {} + var storeId: String { base.storeId } + var coreVersion: String { base.coreVersion } + var clientProtocolVersion: String { base.clientProtocolVersion } + var capabilities: Set { base.capabilities.union(["mysteryCapability"]) } + func create() throws -> any OpenIapModuleProtocol { FixtureModule() } +} +private final class FixedFactory: OpenIapProviderFactory { + let module: FixtureModule + required init() { module = FixtureModule() } + init(_ module: FixtureModule) { self.module = module } + var storeId: String { "community-fixture" } + var coreVersion: String { FixtureFactory().coreVersion } + var clientProtocolVersion: String { FixtureFactory().clientProtocolVersion } + func create() throws -> any OpenIapModuleProtocol { module } +} diff --git a/packages/apple/compatibility/community-provider/Tests/FixtureProviderTests/OpenIapStoreLifecycleTests.swift b/packages/apple/compatibility/community-provider/Tests/FixtureProviderTests/OpenIapStoreLifecycleTests.swift new file mode 100644 index 000000000..03369412a --- /dev/null +++ b/packages/apple/compatibility/community-provider/Tests/FixtureProviderTests/OpenIapStoreLifecycleTests.swift @@ -0,0 +1,574 @@ +import Foundation +import XCTest +import OpenIAP +import FixtureProvider + +final class OpenIapStoreLifecycleTests: XCTestCase { + @MainActor + func testCancelledReinitializationPreservesTheActiveSession() async throws { + let gate = DisconnectGate() + let provider = LifecycleProvider() + let delivered = expectation(description: "Active listener survives cancelled init") + let store = OpenIapStore(onPurchaseSuccess: { _ in delivered.fulfill() }, module: provider) + try await store.initConnection() + let retry = Task { + await gate.wait() + try await store.initConnection() + } + await gate.waitUntilEntered() + retry.cancel() + await gate.release() + do { + try await retry.value + XCTFail("Cancelled initialization must reject") + } catch is CancellationError { } + XCTAssertTrue(store.isConnected) + XCTAssertTrue(provider.isConnected) + XCTAssertEqual(provider.connectionCount, 1) + provider.emit(.purchaseIos(try FixtureModule().makePurchase())) + await fulfillment(of: [delivered], timeout: 1) + try await store.endConnection() + } + + @MainActor + func testFailedReinitializationPreservesTheActiveSession() async throws { + let provider = LifecycleProvider() + let store = OpenIapStore(module: provider) + try await store.initConnection() + provider.initError = PurchaseError(code: .networkError, message: "Retry connection") + do { + try await store.initConnection() + XCTFail("Failed initialization must reject") + } catch let error as PurchaseError { + XCTAssertEqual(error.code, .networkError) + } + XCTAssertTrue(store.isConnected) + XCTAssertTrue(provider.isConnected) + XCTAssertEqual(provider.listenerCount, 1) + try await store.endConnection() + } + + @MainActor + func testSecondTeardownWaitsForReconnectAndKeepsStateConsistent() async throws { + try await assertReconnectThenTeardown(requestBeforeReconnect: false) + } + + @MainActor + func testTeardownQueuedAfterReconnectDoesNotShareEarlierTeardown() async throws { + try await assertReconnectThenTeardown(requestBeforeReconnect: true) + } + + @MainActor + private func assertReconnectThenTeardown(requestBeforeReconnect: Bool) async throws { + let firstEnd = DisconnectGate() + let reconnect = DisconnectGate() + let earlyTeardown = expectation(description: "Teardown cannot overtake initialization") + earlyTeardown.isInverted = true + let reconnectRequested = expectation(description: "Reconnect requested") + let requested = expectation(description: "Second teardown requested") + let delivered = expectation(description: "Next connection owns a working listener") + let provider = LifecycleProvider( + initGates: [nil, reconnect], endGates: [firstEnd], + onDisconnect: { if $0 == 2 { earlyTeardown.fulfill() } } + ) + let store = OpenIapStore(onPurchaseSuccess: { _ in delivered.fulfill() }, module: provider) + try await store.initConnection() + let ending = Task { try await store.endConnection() } + await firstEnd.waitUntilEntered() + let reconnecting = Task { + reconnectRequested.fulfill() + try await store.initConnection() + } + await fulfillment(of: [reconnectRequested], timeout: 1) + if !requestBeforeReconnect { + await firstEnd.release() + try await ending.value + await reconnect.waitUntilEntered() + } + let endingAgain = Task { + requested.fulfill() + try await store.endConnection() + } + await fulfillment(of: [requested], timeout: 1) + if requestBeforeReconnect { + await firstEnd.release() + try await ending.value + await reconnect.waitUntilEntered() + } + await fulfillment(of: [earlyTeardown], timeout: 0.05) + await reconnect.release() + try await reconnecting.value + try await endingAgain.value + XCTAssertFalse(provider.isConnected) + XCTAssertFalse(store.isConnected) + XCTAssertEqual(provider.listenerCount, 0) + XCTAssertEqual(provider.disconnectCount, 2) + try await store.initConnection() + provider.emit(.purchaseIos(try FixtureModule().makePurchase())) + await fulfillment(of: [delivered], timeout: 1) + XCTAssertTrue(provider.isConnected) + XCTAssertTrue(store.isConnected) + try await store.endConnection() + } + + @MainActor + func testCancelledQueuedReconnectDoesNotReopenAfterTeardown() async throws { + let gate = DisconnectGate() + let requested = expectation(description: "Reconnect requested") + let provider = LifecycleProvider(endGates: [gate]) + let store = OpenIapStore(module: provider) + try await store.initConnection() + let ending = Task { try await store.endConnection() } + await gate.waitUntilEntered() + let reconnecting = Task { + requested.fulfill() + try await store.initConnection() + } + await fulfillment(of: [requested], timeout: 1) + reconnecting.cancel() + await gate.release() + try await ending.value + do { + try await reconnecting.value + XCTFail("Cancelled reconnect must reject") + } catch is CancellationError { } + XCTAssertFalse(store.isConnected) + XCTAssertFalse(provider.isConnected) + XCTAssertEqual(provider.connectionCount, 1) + XCTAssertEqual(provider.listenerCount, 0) + } + + @MainActor + func testFailedDisconnectPreservesSessionAndAllowsRetry() async throws { + for thrown in [false, true] { + let provider = LifecycleProvider() + provider.endResult = false + provider.endError = thrown ? PurchaseError(code: .networkError, message: "Retry teardown") : nil + let delivered = expectation(description: "Failed teardown keeps listeners") + let store = OpenIapStore(onPurchaseSuccess: { _ in delivered.fulfill() }, module: provider) + try await store.initConnection() + do { + try await store.endConnection() + XCTFail("Failed teardown must reject") + } catch let error as PurchaseError { + XCTAssertEqual(error.code, thrown ? .networkError : .notEnded) + } + XCTAssertTrue(store.isConnected) + XCTAssertEqual(provider.listenerCount, 1) + provider.emit(.purchaseIos(try FixtureModule().makePurchase())) + await fulfillment(of: [delivered], timeout: 1) + provider.endError = nil + provider.endResult = true + try await store.endConnection() + XCTAssertFalse(store.isConnected) + XCTAssertEqual(provider.listenerCount, 0) + } + } + + @MainActor + func testPendingDisconnectCannotStartAutomaticRefresh() async throws { + let gate = DisconnectGate() + let unexpected = expectation(description: "No automatic query during teardown") + unexpected.isInverted = true + let provider = LifecycleProvider(endGates: [gate], onAvailableRead: { unexpected.fulfill() }) + let delivered = expectation(description: "Pending session still delivers purchase") + let store = OpenIapStore(onPurchaseSuccess: { _ in delivered.fulfill() }, module: provider) + try await store.initConnection() + let ending = Task { try await store.endConnection() } + await gate.waitUntilEntered() + provider.emit(try makeSubscriptionPurchase()) + await fulfillment(of: [delivered], timeout: 1) + await fulfillment(of: [unexpected], timeout: 0.05) + await gate.release() + try await ending.value + XCTAssertFalse(store.isConnected) + XCTAssertEqual(provider.listenerCount, 0) + } + + @MainActor + func testSubscriptionPurchaseUpdatesActiveSubscriptionsBeforeRefreshCompletes() async throws { + let gate = PurchaseQueryGate() + let refreshed = expectation(description: "Refresh completes") + let provider = LifecycleProvider(gates: [gate], onActiveRead: { refreshed.fulfill() }) + let store = OpenIapStore(module: provider) + try await store.initConnection() + let subscription = try makeSubscriptionPurchase() + provider.emit(subscription) + await gate.waitUntilEntered() + XCTAssertEqual(store.activeSubscriptions.map(\.transactionId), [subscription.id]) + XCTAssertEqual(store.activeSubscriptions.first?.isActive, true) + await gate.release([]) + await fulfillment(of: [refreshed], timeout: 1) + try await store.endConnection() + } + + @MainActor + func testAuthoritativeRefreshReplacesOptimisticEntry() async throws { + let gate = PurchaseQueryGate() + let refreshed = expectation(description: "Refresh reaches authoritative read") + let provider = LifecycleProvider(gates: [gate], onActiveRead: { refreshed.fulfill() }) + provider.activeSubscriptionsResult = [ + ActiveSubscription( + isActive: true, + productId: "authoritative.sku", + transactionDate: 2.0, + transactionId: "authoritative-txn" + ) + ] + let store = OpenIapStore(module: provider) + try await store.initConnection() + let subscription = try makeSubscriptionPurchase() + provider.emit(subscription) + await gate.waitUntilEntered() + XCTAssertEqual(store.activeSubscriptions.map(\.transactionId), [subscription.id]) + await gate.release([]) + await fulfillment(of: [refreshed], timeout: 1) + await waitForActiveTransactionIds(store, ["authoritative-txn"]) + XCTAssertEqual(store.activeSubscriptions.map(\.transactionId), ["authoritative-txn"]) + try await store.endConnection() + } + + @MainActor + func testEmptyAuthoritativeRefreshClearsOptimisticEntry() async throws { + let gate = PurchaseQueryGate() + let refreshed = expectation(description: "Empty refresh reaches authoritative read") + let provider = LifecycleProvider(gates: [gate], onActiveRead: { refreshed.fulfill() }) + let store = OpenIapStore(module: provider) + try await store.initConnection() + let subscription = try makeSubscriptionPurchase() + provider.emit(subscription) + await gate.waitUntilEntered() + XCTAssertEqual(store.activeSubscriptions.map(\.transactionId), [subscription.id]) + await gate.release([]) + await fulfillment(of: [refreshed], timeout: 1) + await waitForActiveTransactionIds(store, []) + XCTAssertTrue(store.activeSubscriptions.isEmpty) + try await store.endConnection() + } + + @MainActor + func testUpgradedTransactionSkipsOptimisticEntry() async throws { + let gate = PurchaseQueryGate() + let provider = LifecycleProvider(gates: [gate]) + let store = OpenIapStore(module: provider) + try await store.initConnection() + var ios = try FixtureModule().makePurchase(sku: "upgraded.subscription") + ios.isAutoRenewing = true + ios.expirationDateIOS = Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000 + ios.subscriptionGroupIdIOS = "group-upgraded" + ios.isUpgradedIOS = true + provider.emit(.purchaseIos(ios)) + await gate.waitUntilEntered() + XCTAssertTrue(store.activeSubscriptions.isEmpty) + await gate.release([]) + await waitForActiveTransactionIds(store, []) + XCTAssertTrue(store.activeSubscriptions.isEmpty) + try await store.endConnection() + } + + @MainActor + func testDuplicateTransactionDoesNotDuplicateOptimisticEntry() async throws { + let first = PurchaseQueryGate() + let second = PurchaseQueryGate() + let provider = LifecycleProvider(gates: [first, second]) + let store = OpenIapStore(module: provider) + try await store.initConnection() + let subscription = try makeSubscriptionPurchase() + provider.emit(subscription) + await first.waitUntilEntered() + provider.emit(subscription) + await second.waitUntilEntered() + XCTAssertEqual(store.activeSubscriptions.map(\.transactionId), [subscription.id]) + await second.release([]) + await first.release([]) + await waitForActiveTransactionIds(store, []) + try await store.endConnection() + } + + @MainActor + func testConsumablePurchaseDoesNotTriggerRefresh() async throws { + let availableRead = expectation(description: "No available-purchases query for consumables") + availableRead.isInverted = true + let activeRead = expectation(description: "No subscription query for consumables") + activeRead.isInverted = true + let provider = LifecycleProvider(onAvailableRead: { availableRead.fulfill() }, onActiveRead: { activeRead.fulfill() }) + let delivered = expectation(description: "Consumable still delivered") + let store = OpenIapStore(onPurchaseSuccess: { _ in delivered.fulfill() }, module: provider) + try await store.initConnection() + let consumable = Purchase.purchaseIos(try FixtureModule().makePurchase(sku: "consumable")) + provider.emit(consumable) + await fulfillment(of: [delivered], timeout: 1) + await fulfillment(of: [availableRead, activeRead], timeout: 0.05) + XCTAssertEqual(store.availablePurchases.map(\.id), [consumable.id]) + XCTAssertTrue(store.activeSubscriptions.isEmpty) + try await store.endConnection() + } + + @MainActor + func testReconnectWaitsForPendingDisconnect() async throws { + let gate = DisconnectGate() + let provider = LifecycleProvider(endGates: [gate]) + let store = OpenIapStore(module: provider) + try await store.initConnection() + let ending = Task { try await store.endConnection() } + await gate.waitUntilEntered() + let reconnecting = Task { try await store.initConnection() } + await gate.release() + try await ending.value + try await reconnecting.value + XCTAssertTrue(store.isConnected) + XCTAssertEqual(provider.connectionCount, 2) + XCTAssertEqual(provider.listenerCount, 1) + } + + @MainActor + func testAutomaticRefreshCannotPublishOrReconnectAfterDisconnect() async throws { + let gate = PurchaseQueryGate() + let unexpected = expectation(description: "No subscription query after disconnect") + unexpected.isInverted = true + let provider = LifecycleProvider(gates: [gate], onActiveRead: { unexpected.fulfill() }) + let store = OpenIapStore(module: provider) + try await store.initConnection() + let original = try makeSubscriptionPurchase(sku: "original") + provider.emit(original) + await gate.waitUntilEntered() + try await store.endConnection() + await gate.release([.purchaseIos(try FixtureModule().makePurchase(sku: "stale"))]) + await fulfillment(of: [unexpected], timeout: 0.05) + XCTAssertEqual(store.availablePurchases.map(\.id), [original.id]) + XCTAssertEqual(store.activeSubscriptions.map(\.transactionId), [original.id]) + XCTAssertFalse(store.isConnected) + XCTAssertEqual(provider.connectionCount, 1) + } + + @MainActor + func testSuspendedAutomaticReadDoesNotRetainTheStore() async throws { + let gate = PurchaseQueryGate() + let provider = LifecycleProvider(gates: [gate]) + var store: OpenIapStore? = OpenIapStore(module: provider) + try await store?.initConnection() + provider.emit(try makeSubscriptionPurchase()) + await gate.waitUntilEntered() + weak var releasedStore = store + store = nil + XCTAssertNil(releasedStore) + XCTAssertEqual(provider.listenerCount, 0) + await gate.release([]) + } + + @MainActor + func testNewerAutomaticRefreshWinsWhenOlderReadCompletesLast() async throws { + let first = PurchaseQueryGate() + let second = PurchaseQueryGate() + let refreshed = expectation(description: "Latest refresh completes") + let provider = LifecycleProvider(gates: [first, second], onActiveRead: { refreshed.fulfill() }) + let store = OpenIapStore(module: provider) + try await store.initConnection() + provider.emit(try makeSubscriptionPurchase(sku: "first")) + await first.waitUntilEntered() + provider.emit(try makeSubscriptionPurchase(sku: "second")) + await second.waitUntilEntered() + let latest = Purchase.purchaseIos(try FixtureModule().makePurchase(sku: "latest")) + await second.release([latest]) + await fulfillment(of: [refreshed], timeout: 1) + await first.release([.purchaseIos(try FixtureModule().makePurchase(sku: "stale"))]) + let unexpected = expectation(description: "Older refresh stays cancelled") + unexpected.isInverted = true + provider.onActiveRead = { unexpected.fulfill() } + await fulfillment(of: [unexpected], timeout: 0.05) + XCTAssertEqual(store.availablePurchases.map(\.id), [latest.id]) + try await store.endConnection() + } + + @MainActor + func testReconnectReceivesProviderReplayDuringInitialization() async throws { + let provider = LifecycleProvider() + let replayed = expectation(description: "Reconnected provider replay") + let store = OpenIapStore(onPurchaseSuccess: { _ in replayed.fulfill() }, module: provider) + try await store.initConnection() + try await store.endConnection() + let replay = Purchase.purchaseIos(try FixtureModule().makePurchase(sku: "restored")) + provider.initReplay = replay + try await store.initConnection() + await fulfillment(of: [replayed], timeout: 1) + XCTAssertEqual(store.currentPurchase?.id, replay.id) + try await store.endConnection() + } + + private func makeSubscriptionPurchase(sku: String = "conformance.subscription") throws -> Purchase { + var ios = try FixtureModule().makePurchase(sku: sku) + ios.isAutoRenewing = true + ios.expirationDateIOS = Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000 + ios.subscriptionGroupIdIOS = "group-\(sku)" + return .purchaseIos(ios) + } + + @MainActor + private func waitForActiveTransactionIds(_ store: OpenIapStore, _ ids: [String]) async { + let deadline = Date().addingTimeInterval(1) + while store.activeSubscriptions.map(\.transactionId) != ids, Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + @MainActor + func testCapturedCallbackIsIgnoredAfterDisconnect() async throws { + let provider = LifecycleProvider() + let unexpected = expectation(description: "Removed listener stays removed") + unexpected.isInverted = true + let store = OpenIapStore(onPurchaseSuccess: { _ in unexpected.fulfill() }, module: provider) + try await store.initConnection() + let delivery = provider.captureDelivery(.purchaseIos(try FixtureModule().makePurchase())) + try await store.endConnection() + delivery() + await fulfillment(of: [unexpected], timeout: 0.05) + XCTAssertNil(store.currentPurchase) + } +} + +private actor DisconnectGate { + private var result: CheckedContinuation? + private var entered: CheckedContinuation? + func wait() async { + await withCheckedContinuation { + result = $0 + entered?.resume() + entered = nil + } + } + func waitUntilEntered() async { + if result != nil { return } + await withCheckedContinuation { entered = $0 } + } + func release() { + result?.resume() + result = nil + } +} + +private actor PurchaseQueryGate { + private var result: CheckedContinuation<[Purchase], Never>? + private var entered: CheckedContinuation? + func read() async -> [Purchase] { + await withCheckedContinuation { + result = $0 + entered?.resume() + entered = nil + } + } + func waitUntilEntered() async { + if result != nil { return } + await withCheckedContinuation { entered = $0 } + } + func release(_ purchases: [Purchase]) { + result?.resume(returning: purchases) + result = nil + } +} + +private final class LifecycleProvider: OpenIapModuleProtocol, @unchecked Sendable { + private let fixture = FixtureModule() + private let lock = NSLock() + private var updates: [UUID: PurchaseUpdatedListener] = [:] + private var connections = 0 + private var disconnects = 0 + private var connected = false + private var reads = 0 + private let gates: [PurchaseQueryGate] + private let initGates: [DisconnectGate?] + private let endGates: [DisconnectGate] + private let onDisconnect: @Sendable (Int) -> Void + private let onAvailableRead: @Sendable () -> Void + private var disconnectResult = true + private var disconnectError: PurchaseError? + private var connectionError: PurchaseError? + var initError: PurchaseError? { + get { synchronized { connectionError } } + set { synchronized { connectionError = newValue } } + } + var endResult: Bool { + get { synchronized { disconnectResult } } + set { synchronized { disconnectResult = newValue } } + } + var endError: PurchaseError? { + get { synchronized { disconnectError } } + set { synchronized { disconnectError = newValue } } + } + var initReplay: Purchase? + var activeSubscriptionsResult: [ActiveSubscription] = [] + private var activeRead: @Sendable () -> Void + var onActiveRead: @Sendable () -> Void { + get { synchronized { activeRead } } + set { synchronized { activeRead = newValue } } + } + init( + gates: [PurchaseQueryGate] = [], + initGates: [DisconnectGate?] = [], + endGates: [DisconnectGate] = [], + onDisconnect: @escaping @Sendable (Int) -> Void = { _ in }, + onAvailableRead: @escaping @Sendable () -> Void = {}, + onActiveRead: @escaping @Sendable () -> Void = {} + ) { + self.gates = gates + self.initGates = initGates + self.endGates = endGates + self.onDisconnect = onDisconnect + self.onAvailableRead = onAvailableRead + activeRead = onActiveRead + } + private func synchronized(_ work: () -> T) -> T { + lock.lock(); defer { lock.unlock() }; return work() + } + var connectionCount: Int { synchronized { connections } } + var disconnectCount: Int { synchronized { disconnects } } + var listenerCount: Int { synchronized { updates.count } } + var isConnected: Bool { synchronized { connected } } + func initConnection() async throws -> Bool { + try Task.checkCancellation() + if let initError { throw initError } + let index = synchronized { defer { connections += 1 }; return connections } + if index < initGates.count, let gate = initGates[index] { await gate.wait() } + synchronized { connected = true } + if let initReplay { emit(initReplay) } + return true + } + func endConnection() async throws -> Bool { + let index = synchronized { defer { disconnects += 1 }; return disconnects } + onDisconnect(index + 1) + if index < endGates.count { await endGates[index].wait() } + if let endError { throw endError } + let result = endResult + if result { synchronized { connected = false } } + return result + } + func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult { try await fixture.fetchProducts(params) } + func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { nil } + func restorePurchases() async throws {} + func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] { + onAvailableRead() + let index = synchronized { defer { reads += 1 }; return reads } + return index < gates.count ? await gates[index].read() : [] + } + func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws {} + func getActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] { + onActiveRead() + return synchronized { activeSubscriptionsResult } + } + func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool { false } + func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { try await fixture.verifyPurchase(props) } + func getStorefront() async throws -> String { "US" } + func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws {} + func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener, options: PurchaseUpdatedListenerOptions?) -> Subscription { + let token = Subscription(eventType: .purchaseUpdated) + synchronized { updates[token.id] = listener } + return token + } + func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription { Subscription(eventType: .purchaseError) } + func removeListener(_ subscription: Subscription) { _ = synchronized { updates.removeValue(forKey: subscription.id) } } + func removeAllListeners() { synchronized { updates.removeAll() } } + func captureDelivery(_ purchase: Purchase) -> @Sendable () -> Void { + let listeners = synchronized { Array(updates.values) } + return { listeners.forEach { $0(purchase) } } + } + func emit(_ purchase: Purchase) { captureDelivery(purchase)() } +} diff --git a/packages/apple/compatibility/community-provider/consumer/Package.swift b/packages/apple/compatibility/community-provider/consumer/Package.swift new file mode 100644 index 000000000..207c09b06 --- /dev/null +++ b/packages/apple/compatibility/community-provider/consumer/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 5.9 +import PackageDescription +let package = Package( + name: "FixtureConsumer", platforms: [.macOS(.v14)], + dependencies: [.package(name: "OpenIAP", path: "../../../../.."), .package(name: "FixtureProvider", path: "..")], + targets: [.executableTarget(name: "FixtureConsumer", dependencies: [ + .product(name: "OpenIAP", package: "OpenIAP"), + .product(name: "FixtureProvider", package: "FixtureProvider"), + ], linkerSettings: [.unsafeFlags(["-Xlinker", "-ObjC", "-Xlinker", "-dead_strip"])])] +) diff --git a/packages/apple/compatibility/community-provider/consumer/Sources/FixtureConsumer/main.swift b/packages/apple/compatibility/community-provider/consumer/Sources/FixtureConsumer/main.swift new file mode 100644 index 000000000..dc66e5053 --- /dev/null +++ b/packages/apple/compatibility/community-provider/consumer/Sources/FixtureConsumer/main.swift @@ -0,0 +1,22 @@ +import Foundation +import OpenIAP + +@main struct Smoke { + static func main() async throws { + let path = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".bundle") + try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: path) } + let info: [String: Any] = ["CFBundleIdentifier": "dev.hyo.provider-fixture", OpenIapProvider.metadataKey: "CommunityFixtureFactory"] + let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try data.write(to: path.appendingPathComponent("Info.plist")) + guard let bundle = Bundle(url: path) else { fatalError("Fixture bundle did not load") } + let module = OpenIapModule(factory: try OpenIapProvider.factory(bundle: bundle)) + guard module.storeId == "community-fixture", try await module.initConnection() else { fatalError("Provider did not load") } + let request = try OpenIapSerialization.decode(object: ["type": "in-app", "requestPurchase": ["apple": ["sku": "conformance.product"]]], as: RequestPurchaseProps.self) + _ = try await module.requestPurchase(request) + guard let purchase = try await module.getAvailablePurchases(nil).first, purchase.store == .unknown else { fatalError("Provider purchase did not survive") } + try await module.finishTransaction(purchase: purchase, isConsumable: false) + _ = try await module.endConnection() + print("Release discovery, purchase identity and completion passed") + } +} diff --git a/packages/cli/src/checks.mjs b/packages/cli/src/checks.mjs index 1855108d2..ce2620464 100644 --- a/packages/cli/src/checks.mjs +++ b/packages/cli/src/checks.mjs @@ -64,21 +64,21 @@ const APP_BUILD_FILES = [ // Same table as packages/google/gradle/openiap-store.gradle. export const STORE_ALIASES = { - play: "play", - google: "play", - gplay: "play", - googleplay: "play", + "play": "play", + "google": "play", + "gplay": "play", + "googleplay": "play", "google-play": "play", - gms: "play", - horizon: "horizon", - meta: "horizon", - quest: "horizon", - amazon: "amazon", - fire: "amazon", - fireos: "amazon", + "gms": "play", + "horizon": "horizon", + "meta": "horizon", + "quest": "horizon", + "amazon": "amazon", + "fire": "amazon", + "fireos": "amazon", "fire-os": "amazon", - none: "none", - auto: "auto", + "auto": "auto", + "none": "none", }; /** @@ -102,15 +102,19 @@ export function androidStoreChecks(root, framework) { const fireOs = enabled("fireOsEnabled"); const storeEntry = properties?.get("openiapStore"); const storeValue = storeEntry?.value.trim().toLowerCase() ?? ""; - // openIapNormalizeStore: a blank value is absent, and only an alias is a store. - const explicit = - storeValue === "" - ? null - : Object.hasOwn(STORE_ALIASES, storeValue) - ? STORE_ALIASES[storeValue] - : "unknown"; + const providerEntry = properties?.get("openiapProvider"); + const provider = providerEntry?.value.trim() ?? ""; + const validCommunityId = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(storeValue) && + !["apple", "unknown"].includes(storeValue); + // Recognize the id first so a missing provider reports its own fix. + const explicit = storeValue === "" ? null : Object.hasOwn(STORE_ALIASES, storeValue) + ? STORE_ALIASES[storeValue] : validCommunityId ? storeValue : "unknown"; + const community = explicit && !["play", "horizon", "amazon", "auto", "none", "unknown"].includes(explicit); + + // A community id without coordinates pins nothing yet; Gradle refuses it + // before comparing against any legacy flag. const pinned = - explicit !== null && explicit !== "auto" && explicit !== "unknown"; + explicit !== null && explicit !== "auto" && explicit !== "unknown" && (!community || provider); // Flags pin too: fireOsEnabled picks amazon, and the deprecated // horizonEnabled and openiapPlatform=none pick horizon or opt out. const platformEntry = properties?.get("openiapPlatform"); @@ -143,6 +147,17 @@ export function androidStoreChecks(root, framework) { ), ); } + if ((community && !provider) || (provider && (!community || + !/^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[0-9][A-Za-z0-9_.-]*$/.test(provider) || + provider.startsWith("io.github.hyochan.openiap:openiap-")))) { + findings.push(finding( + "android-store-unknown", "error", "android/gradle.properties", + "Community provider selection is incomplete or invalid.", + "Use play, horizon, amazon, or auto; or pair a lowercase community openiapStore id with fixed openiapProvider=group:artifact:version coordinates.", + {line: providerEntry?.line ?? storeEntry?.line}, + )); + } + if (platformEntry && !optOut) { findings.push( finding( @@ -248,7 +263,7 @@ export function androidStoreChecks(root, framework) { selects !== "none" && !computed && stores.length > 0 && - !stores.includes(selects); + !stores.includes(provider ? "provider" : selects); const declared = stores.length === 1 && !computed ? stores[0] : null; const line = strategies[0]?.number; @@ -285,7 +300,7 @@ export function androidStoreChecks(root, framework) { ? `gradle.properties selects the ${store} store (${pinKey}), and the build computes its flavor from it.` : `This Android project is pinned to the ${store} store (${pinKey}).`; const pin = pinned - ? "the openiapStore pin" + ? provider ? "both openiapStore and openiapProvider" : "the openiapStore pin" : legacy === "horizon" ? `${pinKey}, a deprecated pin,` : pinKey; diff --git a/packages/cli/test/doctor.test.mjs b/packages/cli/test/doctor.test.mjs index 18a1ee896..4bdd2c16c 100644 --- a/packages/cli/test/doctor.test.mjs +++ b/packages/cli/test/doctor.test.mjs @@ -502,7 +502,7 @@ test("gradle.properties is judged the way Gradle judges it", () => { FLUTTER, "openiapStore=bogus\nopeniapPlatform=none\n", ["android-store-unknown"], - /openiapStore=bogus is not a store/u, + /Community provider selection is incomplete or invalid/u, ], [ FLUTTER, @@ -2864,3 +2864,45 @@ test("store flags use Groovy boolean values for both Android stores", () => { (root) => assert.ok(ids(root).includes("android-store-flavor-conflict")), ); }); + +test("an unregistered community provider needs only a fixed coordinate pair", () => { + withProject({...EXPO, "android/gradle.properties": "openiapStore=community-fixture\nopeniapProvider=dev.example:provider:1.0.0\n"}, (root) => { + assert.ok(!ids(root).includes("android-store-unknown")); + assert.ok(!ids(root).includes("android-store-flavor-mismatch")); + }); +}); + +test("a community store uses the neutral provider flavor and Play guidance removes the pair", () => { + withProject({...EXPO, + "android/gradle.properties": "openiapStore=community-fixture\nopeniapProvider=dev.example:provider:1.0.0\n", + "android/app/build.gradle.kts": 'missingDimensionStrategy("platform", "provider")\n', + }, (root) => { + assert.ok(!ids(root).includes("android-store-flavor-mismatch")); + const warning = doctor(root).findings.find((one) => one.id === "android-store-not-play"); + assert.match(warning.fix, /both openiapStore and openiapProvider/); + }); +}); + +test("doctor rejects incomplete or conflicting provider coordinates", () => { + for (const properties of [ + "openiapProvider=dev.example:provider:1.0.0\n", + "openiapStore=community-fixture\n", + "openiapStore=play\nopeniapProvider=dev.example:provider:1.0.0\n", + "openiapStore=community-fixture\nopeniapProvider=dev.example:provider:+\n", + "openiapStore=community-fixture\nopeniapProvider=io.github.hyochan.openiap:openiap-google:3.6.2\n", + "openiapStore=unknown\nopeniapProvider=dev.example:provider:1.0.0\n", + ]) withProject({...EXPO, "android/gradle.properties": properties}, (root) => assert.ok(ids(root).includes("android-store-unknown"))); +}); + +test("a community id without openiapProvider names both fixes", () => { + for (const properties of [ + "openiapStore=community-fixture\n", + "openiapStore=goggle\n", + ]) withProject({...EXPO, "android/gradle.properties": properties}, (root) => { + const unknown = doctor(root).findings.find((one) => one.id === "android-store-unknown"); + assert.match(unknown.message, /Community provider selection is incomplete or invalid/); + assert.match(unknown.fix, /play, horizon, amazon, or auto/); + assert.match(unknown.fix, /openiapProvider/); + assert.match(unknown.fix, /group:artifact:version/); + }); +}); diff --git a/packages/conformance/README.md b/packages/conformance/README.md index 1ac46fe5d..652b26bfa 100644 --- a/packages/conformance/README.md +++ b/packages/conformance/README.md @@ -22,10 +22,10 @@ history. A report states two versions, and neither is optional: -| Field | Meaning | -| -------------- | ----------------------------------------------------------------- | -| `suiteVersion` | Version of this behavior suite (`src/spec/suite-version.mjs`) | -| `clientProtocolVersion` | Client Protocol version validated, read from `openiap-versions.json` | +| Field | Meaning | +| ----------------------- | -------------------------------------------------------------------- | +| `suiteVersion` | Version of this behavior suite (`src/spec/suite-version.mjs`) | +| `clientProtocolVersion` | Client Protocol version validated, read from `openiap-versions.json` | "Conformant" without both attached is exactly the unverifiable claim this suite exists to replace. @@ -37,6 +37,65 @@ passing runs may fail), **minor** when a capability-gated behavior is added, Behavior ids are permanent public identifiers. Renaming one is a breaking change — retire it and add a new id instead. +## Android provider AAR + +`io.github.hyochan.openiap:openiap-conformance:4.0.0` exposes the Kotlin suite +for independent provider repositories. Add it to `testImplementation` and +extend `ProviderConformanceSuite`. Supply a fresh provider, its factory, a +`StoreConformanceAdapter` bound to your production mappers, and a purchasable +`testProductId` in an isolated sandbox user. Also supply `redemptionActivity` +from the host or Robolectric test: declared `offerCodeRedemption` opens the +real flow with that activity, and an undeclared capability is asserted as a +no-op through the provider. Run Android JVM tests with Robolectric or device +instrumentation when the provider needs Android services. + +The suite checks product lookup, purchase events and identity, owned purchases, +stable tokens, idempotent completion, entitlement mapping and normalized errors. +Declare optional capabilities on the factory and adapter using +`pendingPurchases`, `subscriptionBillingIssue`, and `offerCodeRedemption`. +`triggerCapability` drives the store sandbox after listeners are attached; +a declared capability with no trigger or matching event fails. + +Set the test JVM property `openiap.conformanceReport` to an output file, such as +`build/reports/openiap/{storeId}.json`. The report records executed assertions, +`suiteVersion`, `clientProtocolVersion`, capabilities and required behaviors. +A missing required result or any failing assertion makes `conformant` false. +Upload the JSON together with a successful test run in the provider's CI. + +`scope.kind = android-provider` is the registry's provider verification profile. +It does not claim server lifecycle or the complete JavaScript behavior inventory. +`StoreConformanceSuite` alone checks the original mapping profile +(`android-mapping`); that smaller report cannot promote a community provider. +Official stores retain their existing requirements in `capability-matrix.mjs`. + +The source module is `packages/conformance/android`; its AAR depends only on +core, JUnit and store-neutral tooling. It ships separately from billing SDKs. + +## Apple provider Swift product + +Add the public `OpenIapConformance` Swift product from the OpenIAP package to +an independent provider's test target. Implement `ProviderConformanceAdapter` +using the production error and entitlement mappers, a purchasable sandbox SKU, +and triggers for every declared capability. + +```swift +let report = await ProviderConformanceSuite(adapter: adapter).run() +XCTAssertTrue(report.conformant) +try report.write(to: reportURL) +``` + +The `apple-provider` and `android-provider` profiles share mapping, runtime and +capability requirements. Apple pending purchases may arrive as a Pending purchase +or a DeferredPayment error. For billing issues, implement +`billingIssueRetainsEntitlement` from the sandbox's retry or grace state; the +suite compares that expected entitlement with the production mapper. A missing +required callback, unknown capability or failing assertion cannot pass. + +The [independent Swift fixture](../apple/compatibility/community-provider/README.md) +runs this product through public imports and checks optimized factory discovery. +Upload the Codable JSON report with the successful CI run. Registration uses +one store id with separate Apple and Android bindings when both are supported. + ## Running the suite The package is private to this repository; it is not published to npm. @@ -167,10 +226,10 @@ node packages/conformance/scripts/generate-behavior-ids.mjs # write node packages/conformance/scripts/generate-behavior-ids.mjs --check # CI drift gate ``` -| Language | Generated file | -| -------- | ------------------------------------------------------------------------------------------------------ | -| Kotlin | `packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt` | -| Swift | `packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift` | +| Language | Generated file | +| -------- | -------------------------------------------------------------------------------------------------- | +| Kotlin | `packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceBehaviors.kt` | +| Swift | `packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift` | ## Current coverage diff --git a/packages/conformance/android/build.gradle.kts b/packages/conformance/android/build.gradle.kts new file mode 100644 index 000000000..2ad8022e0 --- /dev/null +++ b/packages/conformance/android/build.gradle.kts @@ -0,0 +1,67 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SourcesJar +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") apply false + id("com.vanniktech.maven.publish") apply false +} + +val builtInKotlin = providers.gradleProperty("android.builtInKotlin").orNull?.toBoolean() + ?: (com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.substringBefore('.').toInt() >= 9) +if (!builtInKotlin) pluginManager.apply("org.jetbrains.kotlin.android") + +val conformanceRoot = projectDir.parentFile +val suiteVersion = Regex("SUITE_VERSION = '([^']+)'").find( + conformanceRoot.resolve("src/spec/suite-version.mjs").readText() +)?.groupValues?.get(1) ?: error("Missing conformance suite version") +val standalone = gradle.parent == null && rootProject.projectDir.canonicalFile == + projectDir.resolve("../../google").canonicalFile +if (standalone) pluginManager.apply("com.vanniktech.maven.publish") +else layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("openiap-conformance")) + +android { + namespace = "dev.hyo.openiap.conformance" + compileSdk = 36 + defaultConfig { minSdk = 23 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} +extensions.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xjvm-default=all") + } +} +dependencies { + api(project(":openiap-core")) + api("junit:junit:4.13.2") + implementation("com.google.code.gson:gson:2.14.0") +} + +if (standalone) extensions.configure { + coordinates("io.github.hyochan.openiap", "openiap-conformance", suiteVersion) + configure(AndroidSingleVariantLibrary(variant = "release", sourcesJar = SourcesJar.Sources(), javadocJar = JavadocJar.Empty())) + pom { + name.set("OpenIAP Android Conformance") + description.set("Provider conformance tests for the public OpenIAP Android core") + url.set("https://github.com/hyodotdev/openiap/tree/main/packages/conformance") + licenses { license { name.set("MIT"); url.set("https://opensource.org/licenses/MIT") } } + developers { developer { id.set("hyochan"); name.set("Hyo Dev"); url.set("https://github.com/hyochan") } } + scm { + url.set("https://github.com/hyodotdev/openiap") + connection.set("scm:git:https://github.com/hyodotdev/openiap.git") + developerConnection.set("scm:git:ssh://git@github.com/hyodotdev/openiap.git") + } + } + if (gradle.startParameter.taskNames.any { it.contains("mavenCentral", true) }) { + publishToMavenCentral() + signAllPublications() + } +} diff --git a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceBehaviors.kt similarity index 74% rename from packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt rename to packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceBehaviors.kt index c7233fa3d..76760fc01 100644 --- a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt +++ b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceBehaviors.kt @@ -5,7 +5,11 @@ package dev.hyo.openiap.conformance // packages/conformance/src/spec/behaviors.mjs. object ConformanceBehaviors { - const val SUITE_VERSION = "3.0.0" + const val SUITE_VERSION = "4.0.0" + const val CLIENT_PROTOCOL_VERSION = "0.2.0" + val ANDROID_MAPPING_BEHAVIORS = setOf("subscriptions.active-subscription-is-reported-active", "subscriptions.pending-subscription-is-not-active", "subscriptions.unknown-state-subscription-is-not-active", "subscriptions.groups-keep-independent-identifiers", "errors.store-codes-normalize-to-spec-error-codes", "errors.unrecognized-store-code-normalizes-to-unknown", "identifiers.purchase-carries-a-concrete-store", "capabilities.declared-capabilities-match-the-matrix", "capabilities.unsupported-operations-degrade-predictably") + val ANDROID_PROVIDER_BEHAVIORS = setOf("products.fetch-returns-requested-skus", "purchases.request-emits-purchase-updated-on-success", "provider.invalid-purchase-emits-error-once", "restoration.available-purchases-returns-owned-items", "identifiers.purchase-token-is-stable-across-reads", "completion.finish-is-idempotent") + val PROVIDER_CAPABILITY_BEHAVIORS = mapOf("pendingPurchases" to "purchases.pending-purchase-is-not-delivered-as-purchased", "subscriptionBillingIssue" to "android-provider.subscription-billing-issue", "offerCodeRedemption" to "android-provider.offer-code-redemption") const val PRODUCTS_FETCH_RETURNS_REQUESTED_SKUS = "products.fetch-returns-requested-skus" const val PRODUCTS_FETCH_NORMALIZES_REQUIRED_FIELDS = "products.fetch-normalizes-required-fields" @@ -44,6 +48,11 @@ object ConformanceBehaviors { const val IDENTIFIERS_PURCHASE_TOKEN_IS_STABLE_ACROSS_READS = "identifiers.purchase-token-is-stable-across-reads" const val CAPABILITIES_UNSUPPORTED_OPERATIONS_DEGRADE_PREDICTABLY = "capabilities.unsupported-operations-degrade-predictably" const val CAPABILITIES_DECLARED_CAPABILITIES_MATCH_THE_MATRIX = "capabilities.declared-capabilities-match-the-matrix" + const val PROVIDER_INVALID_PURCHASE_EMITS_ERROR_ONCE = "provider.invalid-purchase-emits-error-once" + const val ANDROID_PROVIDER_SUBSCRIPTION_BILLING_ISSUE = "android-provider.subscription-billing-issue" + const val ANDROID_PROVIDER_OFFER_CODE_REDEMPTION = "android-provider.offer-code-redemption" + const val APPLE_PROVIDER_SUBSCRIPTION_BILLING_ISSUE = "apple-provider.subscription-billing-issue" + const val APPLE_PROVIDER_OFFER_CODE_REDEMPTION = "apple-provider.offer-code-redemption" /** Capability level per store, from specs/client/src/capability-matrix.mjs. */ val CAPABILITY_MATRIX: Map> = mapOf( diff --git a/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceReport.kt b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceReport.kt new file mode 100644 index 000000000..e073ad914 --- /dev/null +++ b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceReport.kt @@ -0,0 +1,50 @@ +package dev.hyo.openiap.conformance + +import com.google.gson.GsonBuilder +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import java.io.File + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConformanceBehavior(vararg val id: String) + +/** Writes only executed assertions; a missing required behavior cannot pass. */ +internal object ConformanceReports { + private val runs = mutableMapOf>() + + fun watcher(suite: StoreConformanceSuite): TestWatcher = object : TestWatcher() { + private var outcome = "pass" + override fun failed(error: Throwable, description: Description) { outcome = "fail" } + override fun skipped(error: org.junit.AssumptionViolatedException, description: Description) { outcome = "not-applicable" } + override fun finished(description: Description) { + val behaviors = description.getAnnotation(ConformanceBehavior::class.java)?.id + ?: if (outcome != "pass") arrayOf("suite.${description.methodName}") else return + val target = System.getProperty("openiap.conformanceReport") ?: return + behaviors.forEach { record(suite, target, it, outcome) } + } + } + + @Synchronized + private fun record(suite: StoreConformanceSuite, target: String, id: String, outcome: String) { + val adapter = suite.reportAdapter + val path = target.replace("{storeId}", adapter.storeId) + val results = runs.getOrPut(path) { mutableMapOf() } + if (results[id] != "fail") results[id] = outcome + val required = suite.requiredReportBehaviors + val report = mapOf( + "suiteVersion" to ConformanceBehaviors.SUITE_VERSION, + "clientProtocolVersion" to ConformanceBehaviors.CLIENT_PROTOCOL_VERSION, + "storeId" to adapter.storeId, + "store" to adapter.store.rawValue, + "capabilities" to adapter.capabilities.map { it.id }.sorted(), + "scope" to mapOf("kind" to suite.reportScope, "requiredBehaviors" to required.sorted(), + "complete" to required.all { results.containsKey(it) }), + "conformant" to (required.all { results[it] == "pass" } && results.values.none { it == "fail" }), + "results" to results.toSortedMap().map { (behavior, verdict) -> mapOf("id" to behavior, "outcome" to verdict) }, + ) + val file = File(path) + file.parentFile?.mkdirs() + file.writeText(GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n") + } +} diff --git a/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ProviderConformanceSuite.kt b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ProviderConformanceSuite.kt new file mode 100644 index 000000000..264a086f2 --- /dev/null +++ b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ProviderConformanceSuite.kt @@ -0,0 +1,236 @@ +package dev.hyo.openiap.conformance + +import dev.hyo.openiap.* +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.CopyOnWriteArrayList +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.Assert.* + +/** Run against a fresh provider with a purchasable test SKU and an isolated test user. */ +abstract class ProviderConformanceSuite : StoreConformanceSuite() { + protected abstract val factory: OpenIapProviderFactory + protected abstract override val provider: OpenIapProtocol + protected open val testProductId: String = "conformance.product" + protected open val timeoutMillis: Long = 5_000 + + /** Drive the store's sandbox or fake backend after the suite attaches listeners. */ + protected open suspend fun triggerCapability(capability: StoreCapability) { + error("Declared ${capability.id} requires a sandbox trigger in this test adapter") + } + + override val reportScope get() = "android-provider" + override val requiredReportBehaviors get() = super.requiredReportBehaviors + REQUIRED_BEHAVIORS + + adapter.capabilities.map { capabilityBehavior(it) } + + @Before fun connectProvider() = runBlocking { + OpenIapProvider.validate(factory.descriptor) + assertEquals(factory.storeId, adapter.storeId) + assertEquals(factory.capabilities, adapter.capabilities.map { it.id }.toSet()) + assertTrue(provider.initConnection(null)) + } + @After fun disconnectProvider() = runBlocking { assertTrue(provider.endConnection()) } + + @Test + @ConformanceBehavior(ConformanceBehaviors.PRODUCTS_FETCH_RETURNS_REQUESTED_SKUS) + fun `fetch returns the requested product`() = runBlocking { + val result = provider.fetchProducts(ProductRequest(skus = listOf(testProductId), type = ProductQueryType.InApp)) + val ids = when (result) { + is FetchProductsResultProducts -> result.value.orEmpty().map { it.id } + is FetchProductsResultAll -> result.value.orEmpty().map { + when (it) { + is ProductOrSubscription.ProductItem -> it.value.id + is ProductOrSubscription.ProductSubscriptionItem -> it.value.id + } + } + is FetchProductsResultSubscriptions -> result.value.orEmpty().map { it.id } + null -> emptyList() + } + assertTrue(ids.contains(testProductId)) + assertTrue(ids.all { it == testProductId }) + } + + @Test + @ConformanceBehavior(ConformanceBehaviors.PROVIDER_INVALID_PURCHASE_EMITS_ERROR_ONCE) + fun `invalid purchase emits one error before completion`() = runBlocking { + val errors = CopyOnWriteArrayList() + val purchased = AtomicBoolean(false) + val updates = OpenIapPurchaseUpdateListener { purchased.set(true) } + val failures = OpenIapPurchaseErrorListener { errors.add(it) } + provider.addPurchaseUpdateListener(updates) + provider.addPurchaseErrorListener(failures) + try { + val request = RequestPurchaseProps.fromJson(mapOf( + "type" to "in-app", + "requestPurchase" to mapOf("google" to mapOf("skus" to emptyList())), + )) + val failure = try { + val result = provider.requestPurchase(request) + assertTrue("A failed request cannot return a purchase", when (result) { + is RequestPurchaseResultPurchase -> result.value == null + is RequestPurchaseResultPurchases -> result.value.isNullOrEmpty() + null -> true + }) + null + } catch (error: kotlinx.coroutines.CancellationException) { + throw error + } catch (error: OpenIapError) { + error + } catch (_: Exception) { + null + } + assertEquals("Emit the error before returning or throwing", 1, errors.size) + if (failure != null) assertEquals(failure.code, errors.single().code) + delay(timeoutMillis) + assertEquals("Request failure must emit exactly once", 1, errors.size) + assertFalse("A failed request cannot deliver a purchase", purchased.get()) + } finally { + provider.removePurchaseUpdateListener(updates) + provider.removePurchaseErrorListener(failures) + } + } + + @Test + @ConformanceBehavior( + ConformanceBehaviors.PURCHASES_REQUEST_EMITS_PURCHASE_UPDATED_ON_SUCCESS, + ConformanceBehaviors.RESTORATION_AVAILABLE_PURCHASES_RETURNS_OWNED_ITEMS, + ConformanceBehaviors.IDENTIFIERS_PURCHASE_TOKEN_IS_STABLE_ACROSS_READS, + ConformanceBehaviors.COMPLETION_FINISH_IS_IDEMPOTENT, + ) + fun `purchase restores and finishes with a stable identity`() = runBlocking { + val purchased = purchase() + assertTrue(purchased is PurchaseAndroid) + assertEquals(testProductId, purchased.productId) + assertEquals(adapter.store, purchased.store) + assertEquals(factory.storeId, purchased.storeId) + assertEquals(purchased.storeId, Purchase.fromJson(purchased.toJson()).storeId) + assertEquals(PurchaseState.Purchased, purchased.purchaseState) + provider.restorePurchases() + suspend fun ownedPurchase(): Purchase { + val owned = provider.getAvailablePurchases(null).first { it.id == purchased.id } + assertTrue(owned is PurchaseAndroid) + assertEquals(adapter.store, owned.store) + assertEquals(factory.storeId, owned.storeId) + assertEquals(purchased.purchaseToken, owned.purchaseToken) + return owned + } + val first = ownedPurchase() + val second = ownedPurchase() + assertFalse(first.purchaseToken.isNullOrBlank()) + assertEquals(first.purchaseToken, second.purchaseToken) + provider.finishTransaction(purchased, false) + provider.finishTransaction(purchased, false) + } + + @Test + @ConformanceBehavior(PENDING_BEHAVIOR) + fun `declared pending capability never grants an entitlement`() = runBlocking { + assumeTrue(StoreCapability.PendingPurchases in adapter.capabilities) + val received = CompletableDeferred() + val errors = CompletableDeferred() + val invalid = AtomicBoolean(false) + val listener = OpenIapPurchaseUpdateListener { + if (it !is PurchaseAndroid || it.store != adapter.store || it.storeId != factory.storeId || + it.purchaseState != PurchaseState.Pending || adapter.toActiveSubscription(it).isActive) invalid.set(true) + received.complete(it) + } + val errorListener = OpenIapPurchaseErrorListener { + if (it.code != ErrorCode.DeferredPayment.rawValue) invalid.set(true) + errors.complete(it) + } + provider.addPurchaseUpdateListener(listener) + provider.addPurchaseErrorListener(errorListener) + try { + try { triggerCapability(StoreCapability.PendingPurchases) } + catch (error: OpenIapError) { + if (error.code != ErrorCode.DeferredPayment.rawValue) throw error + errors.complete(error) + } + withTimeout(timeoutMillis) { + kotlinx.coroutines.selects.select { + received.onAwait { purchase -> + assertEquals(PurchaseState.Pending, purchase.purchaseState) + assertEquals(factory.storeId, purchase.storeId) + assertEquals(adapter.store, purchase.store) + assertFalse(adapter.toActiveSubscription(purchase as PurchaseAndroid).isActive) + } + errors.onAwait { error -> assertEquals(ErrorCode.DeferredPayment.rawValue, error.code) } + } + } + delay(timeoutMillis) + assertFalse("Pending trigger emitted a purchased or contradictory event", invalid.get()) + } finally { + provider.removePurchaseUpdateListener(listener) + provider.removePurchaseErrorListener(errorListener) + } + } + + @Test + @ConformanceBehavior(BILLING_ISSUE_BEHAVIOR) + fun `declared billing issue capability emits a suspended purchase`() = runBlocking { + assumeTrue(StoreCapability.SubscriptionBillingIssue in adapter.capabilities) + val received = CompletableDeferred() + val listener = OpenIapSubscriptionBillingIssueListener { received.complete(it) } + provider.addSubscriptionBillingIssueListener(listener) + try { + triggerCapability(StoreCapability.SubscriptionBillingIssue) + val purchase = withTimeout(timeoutMillis) { received.await() } as PurchaseAndroid + assertEquals(factory.storeId, purchase.storeId) + assertEquals(adapter.store, purchase.store) + assertEquals(true, purchase.isSuspendedAndroid) + assertFalse(adapter.toActiveSubscription(purchase).isActive) + } finally { provider.removeSubscriptionBillingIssueListener(listener) } + } + + @Test + @ConformanceBehavior(REDEMPTION_BEHAVIOR) + fun `declared redemption capability reaches the purchase listener`() = runBlocking { + assumeTrue(StoreCapability.OfferCodeRedemption in adapter.capabilities) + val received = CompletableDeferred() + val listener = OpenIapPurchaseUpdateListener { received.complete(it) } + provider.addPurchaseUpdateListener(listener) + try { + val activity = requireNotNull(redemptionActivity) { + "Declared offerCodeRedemption requires a host or Robolectric Activity" + } + assertTrue(provider.openRedeemOfferCode(activity)) + triggerCapability(StoreCapability.OfferCodeRedemption) + val purchase = withTimeout(timeoutMillis) { received.await() } + assertTrue(purchase is PurchaseAndroid) + assertEquals(factory.storeId, purchase.storeId) + assertEquals(adapter.store, purchase.store) + assertEquals(PurchaseState.Purchased, purchase.purchaseState) + } finally { provider.removePurchaseUpdateListener(listener) } + } + + private suspend fun purchase(): Purchase { + val received = CompletableDeferred() + val listener = OpenIapPurchaseUpdateListener { received.complete(it) } + provider.addPurchaseUpdateListener(listener) + try { + provider.requestPurchase(RequestPurchaseProps.fromJson(mapOf( + "type" to "in-app", + "requestPurchase" to mapOf("google" to mapOf("skus" to listOf(testProductId))), + ))) + return withTimeout(timeoutMillis) { received.await() } + } finally { provider.removePurchaseUpdateListener(listener) } + } + + companion object { + const val PENDING_BEHAVIOR = ConformanceBehaviors.PURCHASES_PENDING_PURCHASE_IS_NOT_DELIVERED_AS_PURCHASED + const val BILLING_ISSUE_BEHAVIOR = ConformanceBehaviors.ANDROID_PROVIDER_SUBSCRIPTION_BILLING_ISSUE + const val REDEMPTION_BEHAVIOR = ConformanceBehaviors.ANDROID_PROVIDER_OFFER_CODE_REDEMPTION + val REQUIRED_BEHAVIORS = ConformanceBehaviors.ANDROID_PROVIDER_BEHAVIORS + fun capabilityBehavior(capability: StoreCapability): String = + ConformanceBehaviors.PROVIDER_CAPABILITY_BEHAVIORS.getValue(capability.id) + } +} diff --git a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt similarity index 89% rename from packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt rename to packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt index d9c426f43..e4c31a014 100644 --- a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt +++ b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt @@ -10,15 +10,15 @@ import dev.hyo.openiap.PurchaseAndroid * Capabilities a store may or may not provide, so "this store cannot do X" is * data rather than a missing test file. */ -enum class StoreCapability { +enum class StoreCapability(val id: String) { /** Store reports a distinct PENDING purchase state (deferred payment). */ - PendingPurchases, + PendingPurchases("pendingPurchases"), /** Store reports subscription billing-issue / suspension signals. */ - SubscriptionBillingIssue, + SubscriptionBillingIssue("subscriptionBillingIssue"), /** Store exposes an offer-code redemption entry point. */ - OfferCodeRedemption, + OfferCodeRedemption("offerCodeRedemption"), } data class StoreErrorCase( @@ -35,6 +35,7 @@ data class StoreErrorCase( interface StoreConformanceAdapter { /** The store discriminator this implementation must stamp on purchases. */ val store: IapStore + val storeId: String get() = if (store == IapStore.Google) "play" else store.rawValue /** Behaviors this store supports. See [StoreCapability]. */ val capabilities: Set @@ -47,9 +48,6 @@ interface StoreConformanceAdapter { /** The production mapper's fail-closed result for an unknown native value. */ val unrecognizedError: OpenIapError - - /** A documented unsupported operation result, or null when none is selected. */ - fun unsupportedOperationResult(): Boolean? } fun playBillingErrorCases(mapper: (Int) -> OpenIapError): List = listOf( diff --git a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceSuite.kt similarity index 72% rename from packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt rename to packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceSuite.kt index fee36f244..7c60634fe 100644 --- a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt +++ b/packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceSuite.kt @@ -1,13 +1,17 @@ package dev.hyo.openiap.conformance +import android.app.Activity import dev.hyo.openiap.ErrorCode import dev.hyo.openiap.IapStore +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.PurchaseAndroid import dev.hyo.openiap.PurchaseState +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.Rule /** * Behavioral conformance expectations for every Android store, declared once @@ -19,6 +23,13 @@ import org.junit.Test abstract class StoreConformanceSuite { protected abstract val adapter: StoreConformanceAdapter + /** Provider under test; suites that omit a capability supply theirs to verify the no-op. */ + protected open val provider: OpenIapProtocol? = null + protected open val redemptionActivity: Activity? = null + internal val reportAdapter get() = adapter + open val reportScope: String get() = "android-mapping" + open val requiredReportBehaviors: Set get() = ConformanceBehaviors.ANDROID_MAPPING_BEHAVIORS + @get:Rule val conformanceReport = ConformanceReports.watcher(this) // --- Spec binding ------------------------------------------------------ @@ -56,6 +67,7 @@ abstract class StoreConformanceSuite { // --- Entitlement integrity (assertions with financial consequence) ---- @Test + @ConformanceBehavior(ConformanceBehaviors.SUBSCRIPTIONS_ACTIVE_SUBSCRIPTION_IS_REPORTED_ACTIVE) fun `purchased subscription is an active entitlement`() { val active = adapter.toActiveSubscription( purchase("dev.hyo.martie.premium.monthly", "token-premium", PurchaseState.Purchased), @@ -70,6 +82,7 @@ abstract class StoreConformanceSuite { // Unconditional: producing a Pending state is a capability, but what // Pending means is not negotiable. @Test + @ConformanceBehavior(ConformanceBehaviors.SUBSCRIPTIONS_PENDING_SUBSCRIPTION_IS_NOT_ACTIVE) fun `pending subscription is not an active entitlement`() { val pending = adapter.toActiveSubscription( purchase("dev.hyo.martie.premium.monthly", "token-pending", PurchaseState.Pending), @@ -82,6 +95,7 @@ abstract class StoreConformanceSuite { } @Test + @ConformanceBehavior(ConformanceBehaviors.SUBSCRIPTIONS_UNKNOWN_STATE_SUBSCRIPTION_IS_NOT_ACTIVE) fun `unknown-state subscription is not an active entitlement`() { val unknown = adapter.toActiveSubscription( purchase("dev.hyo.martie.premium.monthly", "token-unknown", PurchaseState.Unknown), @@ -96,6 +110,7 @@ abstract class StoreConformanceSuite { // --- Identifier normalization ---------------------------------------- @Test + @ConformanceBehavior(ConformanceBehaviors.SUBSCRIPTIONS_GROUPS_KEEP_INDEPENDENT_IDENTIFIERS) fun `active subscriptions keep independent product ids for multiple groups`() { val premium = adapter.toActiveSubscription( purchase("dev.hyo.martie.premium.monthly", "token-premium"), @@ -113,6 +128,7 @@ abstract class StoreConformanceSuite { } @Test + @ConformanceBehavior(ConformanceBehaviors.SUBSCRIPTIONS_GROUPS_KEEP_INDEPENDENT_IDENTIFIERS) fun `active subscription carries the purchase token on both token fields`() { val active = adapter.toActiveSubscription( purchase("dev.hyo.martie.premium.monthly", "token-premium"), @@ -126,7 +142,9 @@ abstract class StoreConformanceSuite { // Adapters bind these assertions to their store-native production mapper. @Test + @ConformanceBehavior(ConformanceBehaviors.ERRORS_STORE_CODES_NORMALIZE_TO_SPEC_ERROR_CODES) fun `store response codes normalize to the specified error codes`() { + assertTrue("adapter must exercise its native error mapper", adapter.normativeErrorCases.isNotEmpty()) for (errorCase in adapter.normativeErrorCases) { assertEquals( "${adapter.store}: ${errorCase.nativeCode} must normalize to ${errorCase.expected.rawValue}", @@ -137,6 +155,7 @@ abstract class StoreConformanceSuite { } @Test + @ConformanceBehavior(ConformanceBehaviors.ERRORS_UNRECOGNIZED_STORE_CODE_NORMALIZES_TO_UNKNOWN) fun `unrecognized store response codes normalize to Unknown`() { assertEquals( "${adapter.store}: an unrecognized response code must normalize to Unknown", @@ -146,13 +165,20 @@ abstract class StoreConformanceSuite { } @Test - fun `unsupported offer code redemption returns its documented no-op`() { - val result = adapter.unsupportedOperationResult() - if (StoreCapability.OfferCodeRedemption in adapter.capabilities) { - assertEquals(null, result) - } else { - assertEquals(false, result) + @ConformanceBehavior(ConformanceBehaviors.CAPABILITIES_UNSUPPORTED_OPERATIONS_DEGRADE_PREDICTABLY) + fun `unsupported offer code redemption returns its documented no-op`() = runBlocking { + // A store that declares redemption drives the real flow elsewhere. + if (StoreCapability.OfferCodeRedemption in adapter.capabilities) return@runBlocking + val provider = requireNotNull(provider) { + "${adapter.storeId} omits offerCodeRedemption but supplies no provider to verify the no-op" + } + val activity = requireNotNull(redemptionActivity) { + "${adapter.storeId} omits offerCodeRedemption but supplies no activity to verify the no-op" } + assertFalse( + "${adapter.storeId} must return false when redemption is undeclared", + provider.openRedeemOfferCode(activity), + ) } // --- Capabilities ------------------------------------------------------ @@ -162,6 +188,7 @@ abstract class StoreConformanceSuite { * capability-gated checks silently pass. The matrix is the authority. */ @Test + @ConformanceBehavior(ConformanceBehaviors.CAPABILITIES_DECLARED_CAPABILITIES_MATCH_THE_MATRIX) fun `declared capabilities match the specification matrix`() { val expectations = mapOf( StoreCapability.PendingPurchases to "pendingPurchases", @@ -170,6 +197,7 @@ abstract class StoreConformanceSuite { ) for ((capability, behavior) in expectations) { + if (adapter.store == IapStore.Unknown) continue val level = ConformanceBehaviors.CAPABILITY_MATRIX[behavior] ?.get(adapter.store.name) ?: error("capability matrix has no $behavior entry for ${adapter.store}") @@ -186,11 +214,15 @@ abstract class StoreConformanceSuite { // --- Store discriminator --------------------------------------------- @Test + @ConformanceBehavior(ConformanceBehaviors.IDENTIFIERS_PURCHASE_CARRIES_A_CONCRETE_STORE) fun `adapter declares a concrete store discriminator`() { - assertTrue( - "a store implementation must not report IapStore.Unknown", - adapter.store != IapStore.Unknown, - ) + val official = mapOf(IapStore.Google to "play", IapStore.Horizon to "horizon", IapStore.Amazon to "amazon") + assertTrue("storeId must be a stable Android provider id", adapter.storeId.matches(Regex("[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*"))) + if (adapter.store == IapStore.Unknown) { + assertTrue("community providers cannot reuse official ids", adapter.storeId !in official.values && adapter.storeId !in setOf("apple", "auto", "none", "unknown")) + } else { + assertEquals(official[adapter.store], adapter.storeId) + } } // --- Fixtures --------------------------------------------------------- @@ -214,6 +246,7 @@ abstract class StoreConformanceSuite { quantity = 1, signatureAndroid = null, store = adapter.store, + storeId = adapter.storeId, transactionDate = 1_700_000_000_000.0, transactionId = token, ) diff --git a/packages/conformance/apple/Sources/ProviderBehaviors.swift b/packages/conformance/apple/Sources/ProviderBehaviors.swift new file mode 100644 index 000000000..9738d9c7b --- /dev/null +++ b/packages/conformance/apple/Sources/ProviderBehaviors.swift @@ -0,0 +1,8 @@ +// Generated by packages/conformance/scripts/generate-behavior-ids.mjs. Do not edit. +public enum ProviderBehaviors { + public static let suiteVersion = "4.0.0" + public static let clientProtocolVersion = "0.2.0" + public static let mapping = ["subscriptions.active-subscription-is-reported-active","subscriptions.pending-subscription-is-not-active","subscriptions.unknown-state-subscription-is-not-active","subscriptions.groups-keep-independent-identifiers","errors.store-codes-normalize-to-spec-error-codes","errors.unrecognized-store-code-normalizes-to-unknown","identifiers.purchase-carries-a-concrete-store","capabilities.declared-capabilities-match-the-matrix","capabilities.unsupported-operations-degrade-predictably"] + public static let runtime = ["products.fetch-returns-requested-skus","purchases.request-emits-purchase-updated-on-success","provider.invalid-purchase-emits-error-once","restoration.available-purchases-returns-owned-items","identifiers.purchase-token-is-stable-across-reads","completion.finish-is-idempotent"] + public static let capabilities: [String: String] = ["pendingPurchases": "purchases.pending-purchase-is-not-delivered-as-purchased", "subscriptionBillingIssue": "apple-provider.subscription-billing-issue", "offerCodeRedemption": "apple-provider.offer-code-redemption"] +} diff --git a/packages/conformance/apple/Sources/ProviderConformance.swift b/packages/conformance/apple/Sources/ProviderConformance.swift new file mode 100644 index 000000000..400030bc9 --- /dev/null +++ b/packages/conformance/apple/Sources/ProviderConformance.swift @@ -0,0 +1,324 @@ +import Foundation +import OpenIAP + +public struct ProviderErrorCase { + public let expected: ErrorCode + public let actual: PurchaseError + public init(expected: ErrorCode, actual: PurchaseError) { + self.expected = expected + self.actual = actual + } +} + +public protocol ProviderConformanceAdapter { + var factory: any OpenIapProviderFactory { get } + var provider: any OpenIapModuleProtocol { get } + var testProductId: String { get } + var normativeErrorCases: [ProviderErrorCase] { get } + var unrecognizedError: PurchaseError { get } + func toActiveSubscription(_ purchase: PurchaseIOS) -> ActiveSubscription + func billingIssueRetainsEntitlement(_ purchase: PurchaseIOS) -> Bool + func triggerCapability(_ capability: String) async throws +} + +public extension ProviderConformanceAdapter { + var testProductId: String { "conformance.product" } + func billingIssueRetainsEntitlement(_ purchase: PurchaseIOS) -> Bool { false } + func triggerCapability(_ capability: String) async throws { + throw PurchaseError.make(code: .featureNotSupported, message: "Declared \(capability) needs a sandbox trigger") + } +} + +public struct ProviderConformanceReport: Codable { + public struct Scope: Codable { + public let kind: String + public let requiredBehaviors: [String] + public let complete: Bool + } + public struct Result: Codable { + public let id: String + public let outcome: String + } + public let suiteVersion: String + public let clientProtocolVersion: String + public let storeId: String + public let store: String + public let capabilities: [String] + public let scope: Scope + public let conformant: Bool + public let results: [Result] + + public func write(to url: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(self).write(to: url, options: .atomic) + } +} + +private final class EventProbe: @unchecked Sendable { + private let lock = NSLock() + private var values: [Value] = [] + private var nextIndex = 0 + func receive(_ value: Value) { lock.lock(); values.append(value); lock.unlock() } + private func take() -> Value? { + lock.lock() + defer { lock.unlock() } + guard nextIndex < values.count else { return nil } + defer { nextIndex += 1 } + return values[nextIndex] + } + var receivedValues: [Value] { + lock.lock() + defer { lock.unlock() } + return values + } + func next(timeout: TimeInterval) async throws -> Value { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let value = take() { return value } + try await Task.sleep(nanoseconds: 10_000_000) + } + throw PurchaseError.make(code: .serviceTimeout, message: "Provider event did not arrive") + } +} + +private enum ProviderEvent { + case purchase(Purchase) + case error(PurchaseError) +} + +public struct ProviderConformanceSuite { + private let adapter: any ProviderConformanceAdapter + private let timeout: TimeInterval + public init(adapter: any ProviderConformanceAdapter, eventTimeout: TimeInterval = 5) { + self.adapter = adapter + timeout = eventTimeout + } + + public func run() async -> ProviderConformanceReport { + let factory = adapter.factory + let provider = adapter.provider + let capabilities = factory.capabilities.sorted() + let required = (ProviderBehaviors.mapping + ProviderBehaviors.runtime + + capabilities.compactMap { ProviderBehaviors.capabilities[$0] }).sorted() + var results: [String: String] = [:] + for capability in capabilities where ProviderBehaviors.capabilities[capability] == nil { + results["provider.unknown-capability.\(capability)"] = "fail" + } + func check(_ id: String, _ operation: () async throws -> Bool) async { + do { results[id] = try await operation() ? "pass" : "fail" } + catch { results[id] = "fail" } + } + func report() -> ProviderConformanceReport { + ProviderConformanceReport( + suiteVersion: ProviderBehaviors.suiteVersion, + clientProtocolVersion: ProviderBehaviors.clientProtocolVersion, + storeId: factory.storeId, store: factory.storeId == StoreIds.Apple ? "apple" : "unknown", + capabilities: capabilities, + scope: .init(kind: "apple-provider", requiredBehaviors: required, + complete: required.allSatisfy { results[$0] != nil }), + conformant: required.allSatisfy { results[$0] == "pass" } && !results.values.contains("fail"), + results: results.sorted { $0.key < $1.key }.map { .init(id: $0.key, outcome: $0.value) } + ) + } + do { try OpenIapProvider.validate(factory.descriptor) } + catch { + results["provider.connection"] = "fail" + return report() + } + do { + guard try await provider.initConnection() else { + results["provider.connection"] = "fail" + await check("provider.disconnect") { try await provider.endConnection() } + return report() + } + } catch { + results["provider.connection"] = "fail" + await check("provider.disconnect") { try await provider.endConnection() } + return report() + } + + await check("subscriptions.active-subscription-is-reported-active") { + self.adapter.toActiveSubscription(try self.fixture(state: .purchased)).isActive + } + await check("subscriptions.pending-subscription-is-not-active") { + !self.adapter.toActiveSubscription(try self.fixture(state: .pending)).isActive + } + await check("subscriptions.unknown-state-subscription-is-not-active") { + !self.adapter.toActiveSubscription(try self.fixture(state: .unknown)).isActive + } + await check("subscriptions.groups-keep-independent-identifiers") { + for sku in ["conformance.group-a", "conformance.group-b"] { + let purchase = try self.fixture(sku: sku) + let active = self.adapter.toActiveSubscription(purchase) + if active.productId != sku || active.currentPlanId != sku || active.purchaseToken != purchase.purchaseToken { return false } + } + return true + } + await check("errors.store-codes-normalize-to-spec-error-codes") { + !self.adapter.normativeErrorCases.isEmpty && self.adapter.normativeErrorCases.allSatisfy { $0.actual.code == $0.expected } + } + await check("errors.unrecognized-store-code-normalizes-to-unknown") { + self.adapter.unrecognizedError.code == .unknown + } + await check("identifiers.purchase-carries-a-concrete-store") { + let purchase = try self.fixture() + let data = try JSONEncoder().encode(purchase) + return try JSONDecoder().decode(PurchaseIOS.self, from: data).storeId == factory.storeId + } + await check("capabilities.declared-capabilities-match-the-matrix") { + capabilities.allSatisfy { ProviderBehaviors.capabilities[$0] != nil } + && (factory.storeId != StoreIds.Apple || Set(capabilities) == OpenIapAppleProviderFactory().capabilities) + } + await check("capabilities.unsupported-operations-degrade-predictably") { + if capabilities.contains("offerCodeRedemption") { return true } + do { return try await provider.openRedeemOfferCode() == nil } + catch let error as PurchaseError { return error.code == .featureNotSupported } + } + await check("products.fetch-returns-requested-skus") { + let products = try await provider.fetchProducts(ProductRequest(skus: [self.adapter.testProductId], type: .inApp)) + let ids: [String] + switch products { + case .products(let items): ids = items?.map { $0.id } ?? [] + case .subscriptions(let items): ids = items?.map { $0.id } ?? [] + case .all(let items): ids = items?.map { + switch $0 { + case .product(let product): return product.id + case .productSubscription(let subscription): return subscription.id + } + } ?? [] + } + return !ids.isEmpty && ids.allSatisfy { $0 == self.adapter.testProductId } + } + var ownedPurchase: Purchase? + await check("provider.invalid-purchase-emits-error-once") { + let probe = EventProbe() + let updates = provider.purchaseUpdatedListener({ probe.receive(.purchase($0)) }, options: nil) + let errors = provider.purchaseErrorListener { probe.receive(.error($0)) } + defer { provider.removeListener(updates); provider.removeListener(errors) } + let request = try OpenIapSerialization.decode(object: [ + "type": "in-app", "requestPurchase": ["apple": ["sku": ""]], + ], as: RequestPurchaseProps.self) + var failure: PurchaseError? + do { + switch try await provider.requestPurchase(request) { + case .purchase(let purchase): if purchase != nil { return false } + case .purchases(let purchases): if purchases?.isEmpty == false { return false } + case nil: break + } + } catch let error as PurchaseError { + failure = error + } catch is CancellationError { + throw CancellationError() + } catch { + // The canonical listener event owns a provider's generic failure. + } + let events = probe.receivedValues + guard events.count == 1, case .error(let emitted) = events[0] else { return false } + if let failure, emitted.code != failure.code { return false } + try await Task.sleep(nanoseconds: UInt64(self.timeout * 1_000_000_000)) + return probe.receivedValues.count == 1 + } + await check("purchases.request-emits-purchase-updated-on-success") { + let purchase = try await self.purchase() + guard case .purchaseIos = purchase, + purchase.storeId == factory.storeId, purchase.productId == self.adapter.testProductId, + purchase.purchaseState == .purchased, + purchase.store == (factory.storeId == StoreIds.Apple ? .apple : .unknown) else { return false } + ownedPurchase = purchase + return true + } + await check("restoration.available-purchases-returns-owned-items") { + guard let purchased = ownedPurchase else { return false } + try await provider.restorePurchases() + return try await provider.getAvailablePurchases(nil).contains { + if case .purchaseIos = $0 { + return $0.id == purchased.id && $0.purchaseToken == purchased.purchaseToken + && $0.storeId == factory.storeId && $0.store == purchased.store + } + return false + } + } + await check("identifiers.purchase-token-is-stable-across-reads") { + guard let purchased = ownedPurchase else { return false } + let first = try await provider.getAvailablePurchases(nil).first { $0.id == purchased.id } + let second = try await provider.getAvailablePurchases(nil).first { $0.id == purchased.id } + guard let first, let second, case .purchaseIos = first, case .purchaseIos = second else { return false } + return first.purchaseToken?.isEmpty == false && first.purchaseToken == purchased.purchaseToken + && second.purchaseToken == purchased.purchaseToken + && first.storeId == factory.storeId && second.storeId == factory.storeId + && first.store == purchased.store && second.store == purchased.store + } + await check("completion.finish-is-idempotent") { + guard let purchased = ownedPurchase else { return false } + try await provider.finishTransaction(purchase: purchased, isConsumable: false) + try await provider.finishTransaction(purchase: purchased, isConsumable: false) + return true + } + for capability in capabilities { + // Unknown capabilities already failed above, before any early return. + guard let id = ProviderBehaviors.capabilities[capability] else { continue } + await check(id) { + let probe = EventProbe() + let subscription = capability == "subscriptionBillingIssue" + ? provider.subscriptionBillingIssueListener { probe.receive(.purchase($0)) } + : provider.purchaseUpdatedListener({ probe.receive(.purchase($0)) }, options: nil) + let errors = provider.purchaseErrorListener { probe.receive(.error($0)) } + defer { provider.removeListener(subscription); provider.removeListener(errors) } + if capability == "offerCodeRedemption" { _ = try await provider.openRedeemOfferCode() } + do { try await self.adapter.triggerCapability(capability) } + catch let error as PurchaseError where capability == "pendingPurchases" && error.code == .deferredPayment { + probe.receive(.error(error)) + } + let event = try await probe.next(timeout: self.timeout) + if capability == "pendingPurchases" { + try await Task.sleep(nanoseconds: UInt64(self.timeout * 1_000_000_000)) + return probe.receivedValues.allSatisfy { event in + switch event { + case .error(let error): return error.code == .deferredPayment + case .purchase(let purchase): + guard case .purchaseIos(let ios) = purchase else { return false } + return purchase.storeId == factory.storeId + && purchase.store == (factory.storeId == StoreIds.Apple ? .apple : .unknown) + && purchase.purchaseState == .pending + && !self.adapter.toActiveSubscription(ios).isActive + } + } + } + switch event { + case .error(let error): + return capability == "pendingPurchases" && error.code == .deferredPayment + case .purchase(let purchase): + guard case .purchaseIos(let ios) = purchase, purchase.storeId == factory.storeId, + purchase.store == (factory.storeId == StoreIds.Apple ? .apple : .unknown) else { return false } + if capability == "offerCodeRedemption" { return purchase.purchaseState == .purchased } + let expectedActive = self.adapter.billingIssueRetainsEntitlement(ios) + return (purchase.purchaseState == .purchased || !expectedActive) + && self.adapter.toActiveSubscription(ios).isActive == expectedActive + } + } + } + await check("provider.disconnect") { try await provider.endConnection() } + return report() + } + + private func purchase() async throws -> Purchase { + let probe = EventProbe() + let subscription = adapter.provider.purchaseUpdatedListener({ probe.receive($0) }, options: nil) + defer { adapter.provider.removeListener(subscription) } + let request = try OpenIapSerialization.decode(object: [ + "type": "in-app", "requestPurchase": ["apple": ["sku": adapter.testProductId]], + ], as: RequestPurchaseProps.self) + _ = try await adapter.provider.requestPurchase(request) + return try await probe.next(timeout: timeout) + } + + private func fixture(state: PurchaseState = .purchased, sku: String = "conformance.product") throws -> PurchaseIOS { + try OpenIapSerialization.decode(object: [ + "id": "txn-\(sku)", "transactionId": "txn-\(sku)", "productId": sku, + "store": adapter.factory.storeId == StoreIds.Apple ? "apple" : "unknown", "storeId": adapter.factory.storeId, + "quantity": 1, "isAutoRenewing": true, "currentPlanId": sku, "purchaseState": state.rawValue, + "purchaseToken": "token-\(sku)", "transactionDate": 1_700_000_000_000.0, + ], as: PurchaseIOS.self) + } +} diff --git a/packages/conformance/scripts/coverage-report.mjs b/packages/conformance/scripts/coverage-report.mjs index cc2e382b8..dfd2cc63d 100644 --- a/packages/conformance/scripts/coverage-report.mjs +++ b/packages/conformance/scripts/coverage-report.mjs @@ -55,11 +55,11 @@ const IMPLEMENTATIONS = []; // --- Android stores (one suite, three flavors) ----------------------------- { const generated = read( - 'packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt', + 'packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceBehaviors.kt', ); const table = resolveConstants(generated, /const val (\w+) = "([^"]+)"/g); const suite = read( - 'packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt', + 'packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceSuite.kt', ); const ids = declaredIds(suite, 'private val coveredBehaviors', table, /ConformanceBehaviors\.(\w+)/g); const unsupportedStoreIds = declaredIds( diff --git a/packages/conformance/scripts/generate-behavior-ids.mjs b/packages/conformance/scripts/generate-behavior-ids.mjs index d5d11aecf..53c622ae7 100644 --- a/packages/conformance/scripts/generate-behavior-ids.mjs +++ b/packages/conformance/scripts/generate-behavior-ids.mjs @@ -5,23 +5,44 @@ * * Run with --check to fail instead of writing (used by the parity audit). */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { CAPABILITY_MATRIX, CAPABILITY_STORES, -} from '../../../specs/client/src/capability-matrix.mjs'; -import { BEHAVIORS } from '../src/spec/behaviors.mjs'; -import { SUITE_VERSION } from '../src/spec/suite-version.mjs'; +} from "../../../specs/client/src/capability-matrix.mjs"; +import { ANDROID_PROVIDER_PROFILE } from "../src/spec/android-provider-profile.mjs"; +import { providerProfile } from "../src/spec/provider-profile.mjs"; +import { BEHAVIORS } from "../src/spec/behaviors.mjs"; +import { SUITE_VERSION } from "../src/spec/suite-version.mjs"; + +const defined = new Set(BEHAVIORS.map((behavior) => behavior.id)); +const profileIds = ["android", "ios"].flatMap((platform) => { + const profile = providerProfile(platform); + return [ + ...profile.mapping, + ...profile.runtime, + ...Object.values(profile.capabilities), + ]; +}); +const missing = [...new Set(profileIds)].filter((id) => !defined.has(id)); +if (missing.length > 0) { + throw new Error( + `Provider profile uses undefined behavior ids: ${missing.join(", ")}`, + ); +} -const ROOT = new URL('../../../', import.meta.url); +const ROOT = new URL("../../../", import.meta.url); const TARGETS = { spec: { - path: 'packages/conformance/src/spec/generated-spec.mjs', + path: "packages/conformance/src/spec/generated-spec.mjs", render: () => { const clientProtocolVersion = JSON.parse( - readFileSync(fileURLToPath(new URL('openiap-versions.json', ROOT)), 'utf8'), + readFileSync( + fileURLToPath(new URL("openiap-versions.json", ROOT)), + "utf8", + ), ).clientProtocol; return `// Generated by packages/conformance/scripts/generate-behavior-ids.mjs // Do not edit. Copied from specs/client so the published package is @@ -54,19 +75,31 @@ export function capabilityLevel(behavior, store) { }, }, kotlin: { - path: 'packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt', + path: "packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceBehaviors.kt", render: () => { - const constants = BEHAVIORS.map( - (behavior) => ` const val ${symbol(behavior.id)} = "${behavior.id}"`, - ).join('\n'); - const capabilityRows = ['pendingPurchases', 'subscriptionBillingIssue', 'offerCodeRedemption'] + const constants = [ + ...new Set([ + ...BEHAVIORS.map((behavior) => behavior.id), + ...ANDROID_PROVIDER_PROFILE.runtime, + ]), + ] + .map((id) => ` const val ${symbol(id)} = "${id}"`) + .join("\n"); + const capabilityRows = [ + "pendingPurchases", + "subscriptionBillingIssue", + "offerCodeRedemption", + ] .map((behavior) => { - const stores = ['Google', 'Horizon', 'Amazon'] - .map((store) => `"${store}" to "${CAPABILITY_MATRIX[behavior].stores[store]}"`) - .join(', '); + const stores = ["Google", "Horizon", "Amazon"] + .map( + (store) => + `"${store}" to "${CAPABILITY_MATRIX[behavior].stores[store]}"`, + ) + .join(", "); return ` "${behavior}" to mapOf(${stores}),`; }) - .join('\n'); + .join("\n"); return `package dev.hyo.openiap.conformance @@ -76,6 +109,16 @@ export function capabilityLevel(behavior, store) { object ConformanceBehaviors { const val SUITE_VERSION = "${SUITE_VERSION}" + const val CLIENT_PROTOCOL_VERSION = "${JSON.parse(readFileSync(new URL("specs/client/package.json", ROOT), "utf8")).version}" + val ANDROID_MAPPING_BEHAVIORS = setOf(${ANDROID_PROVIDER_PROFILE.mapping.map(JSON.stringify).join(", ")}) + val ANDROID_PROVIDER_BEHAVIORS = setOf(${ANDROID_PROVIDER_PROFILE.runtime.map(JSON.stringify).join(", ")}) + val PROVIDER_CAPABILITY_BEHAVIORS = mapOf(${Object.entries( + ANDROID_PROVIDER_PROFILE.capabilities, + ) + .map( + ([key, value]) => `${JSON.stringify(key)} to ${JSON.stringify(value)}`, + ) + .join(", ")}) ${constants} @@ -88,11 +131,11 @@ ${capabilityRows} }, }, swift: { - path: 'packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift', + path: "packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift", render: () => { const constants = BEHAVIORS.map( (behavior) => ` static let ${camel(behavior.id)} = "${behavior.id}"`, - ).join('\n'); + ).join("\n"); return `// Generated by packages/conformance/scripts/generate-behavior-ids.mjs // Do not edit. Behavior ids are the versioned public contract; see // packages/conformance/src/spec/behaviors.mjs. @@ -102,21 +145,46 @@ enum ConformanceBehaviors { ${constants} } +`; + }, + }, + swiftProvider: { + path: "packages/conformance/apple/Sources/ProviderBehaviors.swift", + render: () => { + const profile = providerProfile("ios"); + return `// Generated by packages/conformance/scripts/generate-behavior-ids.mjs. Do not edit. +public enum ProviderBehaviors { + public static let suiteVersion = "${SUITE_VERSION}" + public static let clientProtocolVersion = "${JSON.parse(readFileSync(new URL("specs/client/package.json", ROOT), "utf8")).version}" + public static let mapping = ${JSON.stringify(profile.mapping)} + public static let runtime = ${JSON.stringify(profile.runtime)} + public static let capabilities: [String: String] = [${Object.entries( + profile.capabilities, + ) + .map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`) + .join(", ")}] +} `; }, }, }; function symbol(id) { - return id.replace(/[.-]/g, '_').toUpperCase(); + return id.replace(/[.-]/g, "_").toUpperCase(); } function camel(id) { - const parts = id.replace(/\./g, '-').split('-'); - return parts[0] + parts.slice(1).map((part) => part[0].toUpperCase() + part.slice(1)).join(''); + const parts = id.replace(/\./g, "-").split("-"); + return ( + parts[0] + + parts + .slice(1) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join("") + ); } -const check = process.argv.includes('--check'); +const check = process.argv.includes("--check"); const drift = []; for (const [name, target] of Object.entries(TARGETS)) { @@ -124,7 +192,7 @@ for (const [name, target] of Object.entries(TARGETS)) { const expected = target.render(); let actual = null; try { - actual = readFileSync(absolute, 'utf8'); + actual = readFileSync(absolute, "utf8"); } catch { actual = null; } @@ -140,10 +208,10 @@ for (const [name, target] of Object.entries(TARGETS)) { } if (drift.length > 0) { - console.error('Conformance behavior id drift:'); + console.error("Conformance behavior id drift:"); for (const item of drift) console.error(`- ${item}`); - console.error('Run: bun run --cwd packages/conformance generate:ids'); + console.error("Run: bun run --cwd packages/conformance generate:ids"); process.exit(1); } -if (check) console.log('Conformance behavior ids are in sync.'); +if (check) console.log("Conformance behavior ids are in sync."); diff --git a/packages/conformance/src/adapters/reference-adapter.mjs b/packages/conformance/src/adapters/reference-adapter.mjs index e499bc8ea..d7d6564b9 100644 --- a/packages/conformance/src/adapters/reference-adapter.mjs +++ b/packages/conformance/src/adapters/reference-adapter.mjs @@ -284,7 +284,7 @@ export function createReferenceAdapter({ store = 'Google' } = {}) { 'identifiers.purchase-carries-a-concrete-store': async () => { const impl = fresh(); const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }); - assert.notEqual(purchase.store, 'Unknown'); + assert.equal(purchase.storeId, ({Apple: 'apple', Google: 'play', Horizon: 'horizon', Amazon: 'amazon'})[purchase.store]); assert.ok(purchase.store); }, diff --git a/packages/conformance/src/fake-store/reference-implementation.mjs b/packages/conformance/src/fake-store/reference-implementation.mjs index a3dd1576c..ad0e791c0 100644 --- a/packages/conformance/src/fake-store/reference-implementation.mjs +++ b/packages/conformance/src/fake-store/reference-implementation.mjs @@ -29,6 +29,7 @@ export class ReferenceImplementation { constructor(store, { iapStore = 'Google' } = {}) { this.store = store; this.iapStore = iapStore; + this.storeId = ({Apple: 'apple', Google: 'play', Horizon: 'horizon', Amazon: 'amazon'})[iapStore]; this.purchaseUpdatedListeners = []; this.purchaseErrorListeners = []; } @@ -103,6 +104,7 @@ export class ReferenceImplementation { isValid: record?.state === 'purchased', productId: record?.sku, store: this.iapStore, + storeId: this.storeId, }; } @@ -142,6 +144,7 @@ export class ReferenceImplementation { purchaseToken: record.token, purchaseState: record.state === 'purchased' ? 'Purchased' : 'Pending', store: this.iapStore, + storeId: this.storeId, }; } } diff --git a/packages/conformance/src/spec/android-provider-profile.mjs b/packages/conformance/src/spec/android-provider-profile.mjs new file mode 100644 index 000000000..d6ecce6a1 --- /dev/null +++ b/packages/conformance/src/spec/android-provider-profile.mjs @@ -0,0 +1,4 @@ +// Compatibility entry point for Android provider report consumers. +import { providerProfile } from "./provider-profile.mjs"; +export { requiredProviderBehaviors } from "./provider-profile.mjs"; +export const ANDROID_PROVIDER_PROFILE = providerProfile("android"); diff --git a/packages/conformance/src/spec/behaviors.mjs b/packages/conformance/src/spec/behaviors.mjs index 570d74d60..50cc2e067 100644 --- a/packages/conformance/src/spec/behaviors.mjs +++ b/packages/conformance/src/spec/behaviors.mjs @@ -30,6 +30,9 @@ export const BEHAVIOR_CATEGORIES = Object.freeze([ 'verification', 'identifiers', 'capabilities', + 'provider', + 'android-provider', + 'apple-provider', ]); export const BEHAVIOR_LEVELS = Object.freeze(['MUST', 'SHOULD']); @@ -314,7 +317,7 @@ export const BEHAVIORS = Object.freeze([ id: 'identifiers.purchase-carries-a-concrete-store', category: 'identifiers', level: 'MUST', - statement: 'Every purchase declares a concrete IapStore, never Unknown.', + statement: 'Every purchase declares a stable storeId; official stores also declare their IapStore, and community stores use Unknown.', }, { id: 'identifiers.purchase-token-is-stable-across-reads', @@ -339,6 +342,54 @@ export const BEHAVIORS = Object.freeze([ statement: "An implementation's declared capabilities match the specification's capability matrix for its store.", }, + + // Provider-only behaviors are SHOULD for JS clients; providers require them + // through their profiles and native suites. + // --- provider ---------------------------------------------------------- + { + id: 'provider.invalid-purchase-emits-error-once', + category: 'provider', + level: 'SHOULD', + capability: 'requestPurchase', + statement: + 'An invalid purchase request emits exactly one purchase-error event and delivers no purchase, and any thrown failure carries the same ErrorCode as the emitted event.', + }, + + // --- android-provider -------------------------------------------------- + { + id: 'android-provider.subscription-billing-issue', + category: 'android-provider', + level: 'SHOULD', + capability: 'subscriptionBillingIssue', + statement: + 'A triggered billing issue delivers a PurchaseAndroid with isSuspendedAndroid true and the provider store identity to the subscription-billing-issue listener, and the purchase maps to no active entitlement.', + }, + { + id: 'android-provider.offer-code-redemption', + category: 'android-provider', + level: 'SHOULD', + capability: 'offerCodeRedemption', + statement: + 'Opening offer-code redemption with a declared capability delivers the redeemed Purchased PurchaseAndroid with the provider store identity to the purchase-updated listener.', + }, + + // --- apple-provider ---------------------------------------------------- + { + id: 'apple-provider.subscription-billing-issue', + category: 'apple-provider', + level: 'SHOULD', + capability: 'subscriptionBillingIssue', + statement: + 'A triggered billing issue delivers a PurchaseIOS with the provider store identity to the subscription-billing-issue listener, and the mapped entitlement matches the sandbox grace state.', + }, + { + id: 'apple-provider.offer-code-redemption', + category: 'apple-provider', + level: 'SHOULD', + capability: 'offerCodeRedemption', + statement: + 'Opening offer-code redemption with a declared capability delivers the redeemed Purchased PurchaseIOS with the provider store identity to the purchase-updated listener.', + }, ]); /** @param {string} id */ diff --git a/packages/conformance/src/spec/generated-spec.mjs b/packages/conformance/src/spec/generated-spec.mjs index 650fe52b4..eb6bae3bd 100644 --- a/packages/conformance/src/spec/generated-spec.mjs +++ b/packages/conformance/src/spec/generated-spec.mjs @@ -2,7 +2,7 @@ // Do not edit. Copied from specs/client so the published package is // self-contained; specs/client remains the source of truth. -export const CLIENT_PROTOCOL_VERSION = "0.1.1"; +export const CLIENT_PROTOCOL_VERSION = "0.2.0"; export const CAPABILITY_STORES = Object.freeze(["Apple","Google","Amazon","Horizon"]); diff --git a/packages/conformance/src/spec/provider-profile.mjs b/packages/conformance/src/spec/provider-profile.mjs new file mode 100644 index 000000000..f3d1b3a61 --- /dev/null +++ b/packages/conformance/src/spec/provider-profile.mjs @@ -0,0 +1,67 @@ +import { SUITE_VERSION } from "./suite-version.mjs"; + +/** Required assertions for the Apple and Android provider reports. */ +const commonV4 = Object.freeze({ + mapping: [ + "subscriptions.active-subscription-is-reported-active", + "subscriptions.pending-subscription-is-not-active", + "subscriptions.unknown-state-subscription-is-not-active", + "subscriptions.groups-keep-independent-identifiers", + "errors.store-codes-normalize-to-spec-error-codes", + "errors.unrecognized-store-code-normalizes-to-unknown", + "identifiers.purchase-carries-a-concrete-store", + "capabilities.declared-capabilities-match-the-matrix", + "capabilities.unsupported-operations-degrade-predictably", + ], + runtime: [ + "products.fetch-returns-requested-skus", + "purchases.request-emits-purchase-updated-on-success", + "provider.invalid-purchase-emits-error-once", + "restoration.available-purchases-returns-owned-items", + "identifiers.purchase-token-is-stable-across-reads", + "completion.finish-is-idempotent", + ], + capabilities: { + pendingPurchases: + "purchases.pending-purchase-is-not-delivered-as-purchased", + }, +}); + +// Keep a major's profile while any registry entry cites it, or older reports fail validation. +const profiles = new Map([[4, commonV4]]); + +export function providerProfile( + platform, + major = Number(SUITE_VERSION.split(".")[0]), +) { + const profile = profiles.get(major); + if (!profile) throw new Error(`Unsupported provider suite major: ${major}`); + if (!["android", "ios"].includes(platform)) + throw new Error(`Unknown provider platform: ${platform}`); + const prefix = platform === "ios" ? "apple-provider" : "android-provider"; + return { + ...profile, + capabilities: { + ...profile.capabilities, + subscriptionBillingIssue: `${prefix}.subscription-billing-issue`, + offerCodeRedemption: `${prefix}.offer-code-redemption`, + }, + }; +} + +export function requiredProviderBehaviors( + capabilities, + major = Number(SUITE_VERSION.split(".")[0]), + platform = "android", +) { + const profile = providerProfile(platform, major); + return [ + ...profile.mapping, + ...profile.runtime, + ...capabilities.map((capability) => { + const id = profile.capabilities[capability]; + if (!id) throw new Error(`Unknown provider capability: ${capability}`); + return id; + }), + ]; +} diff --git a/packages/conformance/src/spec/suite-version.mjs b/packages/conformance/src/spec/suite-version.mjs index 19a6ba96c..40e201d70 100644 --- a/packages/conformance/src/spec/suite-version.mjs +++ b/packages/conformance/src/spec/suite-version.mjs @@ -6,4 +6,7 @@ * minor — a capability-gated behavior is added * patch — wording or tooling only; verdicts cannot change */ -export const SUITE_VERSION = '3.0.0'; +export const SUITE_VERSION = '4.0.0'; + +/** Current major's adoption date; starts the registry maintenance grace window. */ +export const SUITE_MAJOR_RELEASE_DATE = '2026-10-01'; diff --git a/packages/docs/openiap-versions.json b/packages/docs/openiap-versions.json index 4c0626527..0c28351f3 100644 --- a/packages/docs/openiap-versions.json +++ b/packages/docs/openiap-versions.json @@ -1,5 +1,5 @@ { - "clientProtocol": "0.1.1", + "clientProtocol": "0.2.0", "google": "3.6.2", "apple": "3.6.1" } diff --git a/packages/docs/package.json b/packages/docs/package.json index 84080179e..3d29f7f0d 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -8,7 +8,7 @@ "showcase:metrics:test": "node --test scripts/refresh-showcase-metrics.test.mjs", "dev": "bunx vite", "build": "bun run typecheck && bun scripts/check-commerce-composition.mjs && bunx vite build && node scripts/prerender.mjs", - "test:discoverability": "node --test scripts/prerender.test.mjs", + "test:discoverability": "node --test scripts/prerender.test.mjs scripts/jsx-whitespace.test.mjs", "typecheck": "tsc --noEmit", "lint": "eslint src --max-warnings 0", "preview": "bunx vite preview", diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index c913025be..d6f9ddccd 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Vendor-neutral in-app purchase standard. OpenIAP governs exactly two protocols. The Client Protocol (@hyodotdev/openiap-client-protocol) is the purchase API an app calls across Apple, Google, Meta Horizon and Amazon; openiap-apple, openiap-google and the six framework libraries implement it. The Commerce Protocol (@hyodotdev/openiap-commerce-protocol) is the server-side contract for verification, entitlements and store events; any backend may implement it, and IAPKit is one such implementation. Each protocol is versioned by its own npm package. A version labelled "OpenIAP Spec" in older material belongs to a retired lineage that numbered the client contract 2.x and 3.x in step with the native libraries; the Client Protocol is now versioned on its own from 0.1.0, so its version and a native library version are never comparable numbers. > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-09-30T09:58:35.683Z +> Generated: 2026-10-05T19:08:24.094Z ## Reading instructions for coding assistants @@ -38,6 +38,21 @@ provides a server-side contract and conformance artifacts, not a mobile SDK or a hosted backend. IAPKit is one backend implementation, not a requirement for using OpenIAP SDKs. +For a community store provider, read https://openiap.dev/docs/guides/store-providers. +Its source walkthrough, actual example screens and acceptance brief reference +https://github.com/hyodotdev/openiap-google-amazon-community. Read that repository's +README, provenance, pinned input and VERIFICATION.md before building. It adapts the +upstream Amazon binding as an educational packaging example through the public Expo SDK. +For FireOS apps, recommend the official openiap-google-amazon integration at +https://openiap.dev/docs/setup/store/amazon. Expo registry checks and App Tester +simulation do not establish production readiness or registry parity across every framework. +Select the provider with its community id and fixed coordinates, preserve the +original receipt, and configure an explicit backend verification adapter. A new +store reports store=unknown with its own storeId; a provider serving an existing +store reports that store's canonical storeId and legacy store value. Pending or +rejected purchases remain unfinished. Run public conformance, consumer checks and +store testing separately. + Follow one purchase at https://openiap.dev/commerce-protocol/getting-started. Select Apple, Google Play, Amazon or Meta Horizon there; the chosen store stays with the six-step flow and its build brief. For Quest, open that page and @@ -271,7 +286,7 @@ Requires .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. env for EAS and release builds, which have no device to follow. `modules.amazon.fireOS` also pins Amazon, ahead of the device. The `EXPO_IAP_*` environment flags are deprecated and removed in expo-iap - 6.0.0; an `app.config.js` can read the EAS profile env and set the plugin + 7.0.0; an `app.config.js` can read the EAS profile env and set the plugin option instead. The config plugin otherwise carries store values only: `android.horizon.appId`, `android.amazon.appstoreKey`, the opt-in `modules.onside`, and optional `android.amazon.vegaOS` metadata. A diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index a4ebeb51a..b58a5064d 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Vendor-neutral in-app purchase standard. OpenIAP governs exactly two protocols. The Client Protocol (@hyodotdev/openiap-client-protocol) is the purchase API an app calls across Apple, Google, Meta Horizon and Amazon; openiap-apple, openiap-google and the six framework libraries implement it. The Commerce Protocol (@hyodotdev/openiap-commerce-protocol) is the server-side contract for verification, entitlements and store events; any backend may implement it, and IAPKit is one such implementation. Each protocol is versioned by its own npm package. A version labelled "OpenIAP Spec" in older material belongs to a retired lineage that numbered the client contract 2.x and 3.x in step with the native libraries; the Client Protocol is now versioned on its own from 0.1.0, so its version and a native library version are never comparable numbers. > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-09-30T09:58:35.683Z +> Generated: 2026-10-05T19:08:24.094Z ## Reading instructions for coding assistants @@ -38,6 +38,21 @@ provides a server-side contract and conformance artifacts, not a mobile SDK or a hosted backend. IAPKit is one backend implementation, not a requirement for using OpenIAP SDKs. +For a community store provider, read https://openiap.dev/docs/guides/store-providers. +Its source walkthrough, actual example screens and acceptance brief reference +https://github.com/hyodotdev/openiap-google-amazon-community. Read that repository's +README, provenance, pinned input and VERIFICATION.md before building. It adapts the +upstream Amazon binding as an educational packaging example through the public Expo SDK. +For FireOS apps, recommend the official openiap-google-amazon integration at +https://openiap.dev/docs/setup/store/amazon. Expo registry checks and App Tester +simulation do not establish production readiness or registry parity across every framework. +Select the provider with its community id and fixed coordinates, preserve the +original receipt, and configure an explicit backend verification adapter. A new +store reports store=unknown with its own storeId; a provider serving an existing +store reports that store's canonical storeId and legacy store value. Pending or +rejected purchases remain unfinished. Run public conformance, consumer checks and +store testing separately. + Follow one purchase at https://openiap.dev/commerce-protocol/getting-started. Select Apple, Google Play, Amazon or Meta Horizon there; the chosen store stays with the six-step flow and its build brief. For Quest, open that page and diff --git a/packages/docs/public/store-provider-example/acceptance.png b/packages/docs/public/store-provider-example/acceptance.png new file mode 100644 index 000000000..68684d253 Binary files /dev/null and b/packages/docs/public/store-provider-example/acceptance.png differ diff --git a/packages/docs/public/store-provider-example/catalog.png b/packages/docs/public/store-provider-example/catalog.png new file mode 100644 index 000000000..743f8f269 Binary files /dev/null and b/packages/docs/public/store-provider-example/catalog.png differ diff --git a/packages/docs/public/store-provider-example/completed.png b/packages/docs/public/store-provider-example/completed.png new file mode 100644 index 000000000..45c66b598 Binary files /dev/null and b/packages/docs/public/store-provider-example/completed.png differ diff --git a/packages/docs/public/store-provider-example/home.png b/packages/docs/public/store-provider-example/home.png new file mode 100644 index 000000000..0c3826eb7 Binary files /dev/null and b/packages/docs/public/store-provider-example/home.png differ diff --git a/packages/docs/public/store-provider-example/tutorial-wide.png b/packages/docs/public/store-provider-example/tutorial-wide.png new file mode 100644 index 000000000..11476dd1f Binary files /dev/null and b/packages/docs/public/store-provider-example/tutorial-wide.png differ diff --git a/packages/docs/public/store-provider-example/tutorial.png b/packages/docs/public/store-provider-example/tutorial.png new file mode 100644 index 000000000..2e352f9ae Binary files /dev/null and b/packages/docs/public/store-provider-example/tutorial.png differ diff --git a/packages/docs/scripts/jsx-whitespace.test.mjs b/packages/docs/scripts/jsx-whitespace.test.mjs new file mode 100644 index 000000000..dc09a6efd --- /dev/null +++ b/packages/docs/scripts/jsx-whitespace.test.mjs @@ -0,0 +1,456 @@ +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +// JSX drops whitespace around newlines, so `word\nx` renders +// "wordx". This reports cross-line joins between prose text and an inline +// element or expression. It skips same-line pairs, `{' '}` joins, +// non-inline elements, pairs without prose text (a newline there drops no +// space), comments, and unspaced em/en dash joins. +const here = dirname(fileURLToPath(import.meta.url)); +const docs = join(here, '..'); + +function walk(dir) { + const found = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) found.push(...walk(path)); + else if (entry.name.endsWith('.tsx')) found.push(path); + } + return found.sort(); +} + +const JOIN_END = /[A-Za-z0-9.!?;:'")\]}%]/; +const JOIN_START = /[A-Za-z0-9]/; +const INLINE = new Set([ + 'a', + 'code', + 'em', + 'strong', + 'span', + 'Link', + 'small', + 'sub', + 'sup', +]); + +// JSX text decodes entities so `;` is not misread; literals stay as written. +const MDASH_ENTITY = /&(?:mdash|#8212|#x2014);/gi; +const NDASH_ENTITY = /&(ndash|#8211|#x2013);/gi; +const DASH = /[—–]/; + +function decodeDashes(s) { + return s.replace(MDASH_ENTITY, '—').replace(NDASH_ENTITY, '–'); +} + +function jsxText(raw) { + raw = decodeDashes(raw); + if (!raw.includes('\n')) return raw; + const lines = raw.split('\n'); + let last = 0; + for (let i = 0; i < lines.length; i++) { + if (/[^ \t]/.test(lines[i])) last = i; + } + let out = ''; + for (let i = 0; i < lines.length; i++) { + let line = lines[i].replace(/\t/g, ' '); + if (i !== 0) line = line.replace(/^[ ]+/, ''); + if (i !== lines.length - 1) line = line.replace(/[ ]+$/, ''); + if (line) out += line + (i === last ? '' : ' '); + } + return out; +} + +// Join edge of a decoded text, or null. A dash edge joins only with +// a space on its far side inside the same text. +function joinEdge(text, first) { + if (!text) return null; + const edge = first ? text[0] : text[text.length - 1]; + if ((first ? JOIN_START : JOIN_END).test(edge)) return edge; + if (text.length < 2 || !DASH.test(edge)) return null; + const far = first ? text[1] : text[text.length - 2]; + return /\s/.test(far) ? edge : null; +} + +// First or last decoded text inside an element, or null. +function edgeText(element, first) { + const kids = first ? element.children : [...element.children].reverse(); + for (const child of kids) { + if (ts.isJsxText(child)) { + const text = jsxText(child.text); + if (text) return text; + } else if (ts.isJsxElement(child) || ts.isJsxFragment(child)) { + const found = edgeText(child, first); + if (found) return found; + } else if (ts.isJsxExpression(child)) { + const inner = child.expression; + if (inner && ts.isStringLiteral(inner)) { + if (inner.text) return inner.text; + } else if (inner) { + return null; + } + } + } + return null; +} + +function isSpaceExpr(node) { + return ( + ts.isJsxExpression(node) && + !node.dotDotDotToken && + node.expression && + ts.isStringLiteral(node.expression) && + node.expression.text.trim() === '' + ); +} + +function scanSource(text, path) { + const source = ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const findings = []; + const lineOf = (pos) => source.getLineAndCharacterOfPosition(pos).line + 1; + const check = (children) => { + for (let i = 0; i + 1 < children.length; i++) { + const a = children[i]; + const b = children[i + 1]; + if (isSpaceExpr(a) || isSpaceExpr(b)) continue; + if ( + (ts.isJsxElement(a) && + !INLINE.has(a.openingElement.tagName.getText())) || + (ts.isJsxElement(b) && !INLINE.has(b.openingElement.tagName.getText())) + ) { + continue; + } + let endPos = a.end; + if (ts.isJsxText(a)) { + const last = a.text.search(/\S(?=\s*$)/); + endPos = last < 0 ? a.end : a.getFullStart() + last + 1; + } + if (lineOf(endPos - 1) === lineOf(b.getStart(source))) continue; + let end = null; + let endUnknown = false; + if (ts.isJsxText(a)) { + end = joinEdge(jsxText(a.text), false); + } else if (ts.isJsxElement(a) || ts.isJsxFragment(a)) { + end = joinEdge(edgeText(a, false), false); + } else if (ts.isJsxExpression(a)) { + const inner = a.expression; + if (!inner) continue; + if (!ts.isStringLiteral(inner)) endUnknown = true; + else if (!inner.text) continue; + else end = joinEdge(inner.text, false); + } else { + continue; + } + if (!endUnknown && end === null) continue; + let start = null; + let startUnknown = false; + if (ts.isJsxText(b)) { + start = joinEdge(jsxText(b.text), true); + } else if (ts.isJsxElement(b) || ts.isJsxFragment(b)) { + start = joinEdge(edgeText(b, true), true); + } else if (ts.isJsxExpression(b)) { + const inner = b.expression; + if (!inner) continue; + if (!ts.isStringLiteral(inner)) startUnknown = true; + else if (!inner.text) continue; + else start = joinEdge(inner.text, true); + } else { + continue; + } + if (!startUnknown && start === null) continue; + const show = (char, unknown) => (unknown ? '{expr}' : `'${char}'`); + findings.push( + `${path}:${lineOf(endPos - 1)} ${show(end, endUnknown)} + ${show(start, startUnknown)}` + ); + } + for (const child of children) { + if (ts.isJsxElement(child) || ts.isJsxFragment(child)) { + check(child.children); + } + } + }; + const visit = (node) => { + if (ts.isJsxElement(node) || ts.isJsxFragment(node)) { + check(node.children); + return; + } + ts.forEachChild(node, visit); + }; + visit(source); + return findings; +} + +function scanFile(path) { + return scanSource(readFileSync(path, 'utf8'), path); +} + +const page = (body) => + `export function Fixture() {\n return (\n
\n${body}\n
\n );\n}\n`; + +test('text to inline element joins are still reported', () => { + const flagged = page(`

+ word + x +

`); + assert.equal(scanSource(flagged, 'fixture.tsx').length, 1); +}); + +test('expression neighbors follow the same newline rule', () => { + const flagged = page(`

+ word + {value} +

+

+ {value} + word +

+

+ word + {'suffix'} +

`); + assert.equal(scanSource(flagged, 'fixture.tsx').length, 3); +}); + +test('punctuation literals, comments, and explicit spaces stay clean', () => { + const clean = page(`

+ word + {'.'} +

+

+ word + {/* note */} +

+

+ word{' '} + {value} +

`); + assert.deepEqual(scanSource(clean, 'fixture.tsx'), []); +}); + +test('dash joins are accepted', () => { + const clean = page(`

+ word— + y +

+

+ word– + y +

+

+ word— + y +

+

+ word– + y +

+

+ word— + y +

+

+ word– + y +

+

+ word— + y +

+

+ word– + y +

+

+ x + —word +

+

+ x + — + y +

`); + assert.deepEqual(scanSource(clean, 'fixture.tsx'), []); +}); + +test('one-sided spaced dash joins are reported', () => { + const flagged = page(`

+ word — + y +

+

+ word – + y +

+

+ word — + y +

+

+ word – + y +

+

+ word — + y +

+

+ word – + y +

+

+ word — + y +

+

+ word – + y +

+

+ x + — word +

`); + assert.equal(scanSource(flagged, 'fixture.tsx').length, 9); +}); + +test('element-wrapped dash edges follow the same spacing rule', () => { + const clean = page(`

+ word + —x +

+

+ x— + word +

`); + assert.deepEqual(scanSource(clean, 'fixture.tsx'), []); + const flagged = page(`

+ word + — x +

+

+ x — + word +

+

+ Note — + word +

+

+ word + — note +

`); + assert.equal(scanSource(flagged, 'fixture.tsx').length, 4); +}); + +test('string-literal dash edges follow the same spacing rule', () => { + const clean = page(`

+ {'x—'} + word +

+

+ word + {'—x'} +

`); + assert.deepEqual(scanSource(clean, 'fixture.tsx'), []); + const flagged = page(`

+ word + {'— x'} +

+

+ {'x —'} + word +

+

+ word + {'— x'} +

`); + assert.equal(scanSource(flagged, 'fixture.tsx').length, 3); +}); + +test('string literals keep entities as written', () => { + const flagged = page(`

+ {'x—'} + word +

`); + assert.equal(scanSource(flagged, 'fixture.tsx').length, 1); + const clean = page(`

+ word + {'— x'} +

`); + assert.deepEqual(scanSource(clean, 'fixture.tsx'), []); +}); + +test('element edges use the nearest text in each direction', () => { + const flagged = page(`

+ a b c + word +

+

+ word + a b c +

`); + const findings = scanSource(flagged, 'fixture.tsx'); + assert.equal(findings.length, 2); + assert.ok(findings[0].includes("'c' + 'w'")); + assert.ok(findings[1].includes("'d' + 'a'")); +}); + +test('element edges skip comments and empty literals', () => { + const flagged = page(`

+ word + {/* c */}x +

+

+ word + {''}x +

`); + assert.equal(scanSource(flagged, 'fixture.tsx').length, 2); + const clean = page(`

+ word + {value}x +

`); + assert.deepEqual(scanSource(clean, 'fixture.tsx'), []); +}); + +test('nested element edges recurse inward in the right direction', () => { + const literal = page(`

+ {'x—'} + word +

`); + const literalFindings = scanSource(literal, 'fixture.tsx'); + assert.equal(literalFindings.length, 1); + assert.ok(literalFindings[0].includes("';' + 'w'")); + const nested = page(`

+ word + x +

`); + const nestedFindings = scanSource(nested, 'fixture.tsx'); + assert.equal(nestedFindings.length, 1); + assert.ok(nestedFindings[0].includes("'d' + 'x'")); + const clean = page(`

+ word + {'.'}x +

`); + assert.deepEqual(scanSource(clean, 'fixture.tsx'), []); +}); + +test('element-only joins stay unchecked', () => { + const clean = page(`

+ a + b +

`); + assert.deepEqual(scanSource(clean, 'fixture.tsx'), []); +}); + +test('no cross-line JSX joins in docs pages', () => { + const findings = walk(join(docs, 'src')).flatMap((page) => scanFile(page)); + assert.deepEqual(findings, []); +}); diff --git a/packages/docs/src/generated/store-registry.json b/packages/docs/src/generated/store-registry.json new file mode 100644 index 000000000..82e71dd62 --- /dev/null +++ b/packages/docs/src/generated/store-registry.json @@ -0,0 +1,71 @@ +{ + "suiteVersion": "4.0.0", + "maintenanceWindowDays": 90, + "suiteMajorAdoptionDate": "2026-10-01", + "stores": [ + { + "id": "apple", + "platform": "ios", + "displayName": "Apple App Store", + "tier": "official", + "aliases": [], + "maintainers": ["hyodotdev"], + "repo": "https://github.com/hyodotdev/openiap/tree/main/packages/apple", + "coordinates": "hyodotdev/openiap@3.6.1", + "capabilities": [ + "pendingPurchases", + "subscriptionBillingIssue", + "offerCodeRedemption" + ], + "latestReport": null, + "status": "official", + "reports": [] + }, + { + "id": "play", + "platform": "android", + "displayName": "Google Play", + "tier": "official", + "aliases": ["google", "gplay", "googleplay", "google-play", "gms"], + "maintainers": ["hyodotdev"], + "repo": "https://github.com/hyodotdev/openiap/tree/main/packages/google", + "coordinates": "io.github.hyochan.openiap:openiap-google:3.6.2", + "capabilities": [ + "pendingPurchases", + "subscriptionBillingIssue", + "offerCodeRedemption" + ], + "latestReport": null, + "status": "official", + "reports": [] + }, + { + "id": "horizon", + "platform": "android", + "displayName": "Meta Horizon", + "tier": "official", + "aliases": ["meta", "quest"], + "maintainers": ["hyodotdev"], + "repo": "https://github.com/hyodotdev/openiap/tree/main/packages/google", + "coordinates": "io.github.hyochan.openiap:openiap-google-horizon:3.6.2", + "capabilities": ["pendingPurchases"], + "latestReport": null, + "status": "official", + "reports": [] + }, + { + "id": "amazon", + "platform": "android", + "displayName": "Amazon Appstore", + "tier": "official", + "aliases": ["fire", "fireos", "fire-os"], + "maintainers": ["hyodotdev"], + "repo": "https://github.com/hyodotdev/openiap/tree/main/packages/google", + "coordinates": "io.github.hyochan.openiap:openiap-google-amazon:3.6.2", + "capabilities": ["pendingPurchases"], + "latestReport": null, + "status": "official", + "reports": [] + } + ] +} diff --git a/packages/docs/src/generated/version-metadata.json b/packages/docs/src/generated/version-metadata.json index 78125507f..82f72e9f7 100644 --- a/packages/docs/src/generated/version-metadata.json +++ b/packages/docs/src/generated/version-metadata.json @@ -1,6 +1,6 @@ { "_generatedBy": "scripts/sync-versions.sh", - "clientProtocolPackageVersion": "0.1.1", + "clientProtocolPackageVersion": "0.2.0", "commerceProtocolPackageVersion": "0.3.1", "expoPackageVersion": "5.8.2", "reactNativePackageVersion": "16.7.2", diff --git a/packages/docs/src/pages/commerce-protocol/conformance.tsx b/packages/docs/src/pages/commerce-protocol/conformance.tsx index e5e72514d..2e2165e4a 100644 --- a/packages/docs/src/pages/commerce-protocol/conformance.tsx +++ b/packages/docs/src/pages/commerce-protocol/conformance.tsx @@ -98,10 +98,8 @@ function CommerceConformance() { Implement the EventsAdapter interface in the installed conformance/index.d.ts against your own emitter. An advertised events profile without this adapter fails the run. Read - report.results and report.parityFailures - for failures; the script exits nonzero when - report.ok - {' '} + report.results and report.parityFailures{' '} + for failures; the script exits nonzero when report.ok{' '} is false.

diff --git a/packages/docs/src/pages/commerce-protocol/overview.tsx b/packages/docs/src/pages/commerce-protocol/overview.tsx index 308073aaa..38b09f7b9 100644 --- a/packages/docs/src/pages/commerce-protocol/overview.tsx +++ b/packages/docs/src/pages/commerce-protocol/overview.tsx @@ -71,8 +71,7 @@ function CommerceProtocol(): React.JSX.Element {

- Build your part. - Connect the whole. + Build your part. Connect the whole.

A vendor-neutral specification for the server side of in-app diff --git a/packages/docs/src/pages/docs/apis/end-connection.tsx b/packages/docs/src/pages/docs/apis/end-connection.tsx index e0eb3ed0f..6fec5b5c8 100644 --- a/packages/docs/src/pages/docs/apis/end-connection.tsx +++ b/packages/docs/src/pages/docs/apis/end-connection.tsx @@ -52,6 +52,22 @@ function EndConnection() { reconnect and register fresh listeners before the next store call.

+

+ Godot / Apple providers: A failed disconnection keeps + the current session and listeners available, including when the provider + returns false. Retry teardown; successful disconnection + clears both native and GDScript connection state. +

+ +

+ SwiftUI: OpenIapStore cancels automatic + purchase refreshes and ignores queued callbacks from the ended session. + Reconnecting registers listeners before initialization so provider + ownership replays reach the store. Failed teardown keeps the current + connection and listeners; a provider returning false throws{' '} + not-ended so you can retry. +

+

Signature

{{ diff --git a/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx b/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx index da9e411b9..ed35b4d97 100644 --- a/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx @@ -34,9 +34,7 @@ function GetActiveSubscriptions() { Apple docs . Android: Calls queryPurchasesAsync(SUBS){' '} - and treats{' '} - purchaseState == PURCHASED && autoRenewing as - active.{' '} + and treats purchaseState == PURCHASED as active.{' '} Fire OS: uses the Amazon adapter's purchase-update stream under the Android API shape. App code still passes the same - subscription SKU list and reads the same ActiveSubscription + subscription SKU list and reads the same ActiveSubscription{' '} fields; the adapter handles Amazon receipt IDs and the in-flight purchase response correlation so examples and framework apps do not need ad-hoc SKU alias logic. @@ -149,8 +147,9 @@ func get_active_subscriptions_result(subscription_ids: Array[String] = []) -> Di (required, boolean) {' '} - — true while the subscription is in a paying or grace - state. + — true on iOS while the expiration date is in the future + (billing grace reads inactive); on Android while the purchase state is + purchased.
  • expirationDateIOS{' '} @@ -176,6 +175,13 @@ func get_active_subscriptions_result(subscription_ids: Array[String] = []) -> Di cycle.
  • +

    + When upgrading from an older major, see the{' '} + + subscription-check upgrade notes + + . +

    Example

    diff --git a/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx b/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx index 31a5dc855..7f27c56b7 100644 --- a/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx @@ -21,8 +21,9 @@ function HasActiveSubscriptions() {

    Quick check if the user has any active subscriptions.

    iOS: Convenience over{' '} - getActiveSubscriptions — returns true if the - iterator yields at least one non-expired subscription.{' '} + getActiveSubscriptions — returns true if at + least one matching subscription has the active flag set (expiration in + the future); a subscriber in billing grace reads inactive.{' '} Di getActiveSubscriptions {' '} - when you only need a yes/no answer. + when you only need a yes/no answer. When upgrading from an older major, + see the{' '} + + subscription-check upgrade notes + + .

    Only a successful empty query resolves false. Godot diff --git a/packages/docs/src/pages/docs/apis/init-connection.tsx b/packages/docs/src/pages/docs/apis/init-connection.tsx index 8b1bd5221..f7b75f08b 100644 --- a/packages/docs/src/pages/docs/apis/init-connection.tsx +++ b/packages/docs/src/pages/docs/apis/init-connection.tsx @@ -106,7 +106,7 @@ function InitConnection() { — 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); + EXTERNAL_PAYMENTS requires Billing 8.3.0+ (Japan only);{' '} BILLING_CHOICE is available in OpenIAP 2.1.0 /{' '} openiap-google 2.3.0 and requires Billing 9.1.0+. diff --git a/packages/docs/src/pages/docs/apis/restore-purchases.tsx b/packages/docs/src/pages/docs/apis/restore-purchases.tsx index abb1cc1ea..edab62ada 100644 --- a/packages/docs/src/pages/docs/apis/restore-purchases.tsx +++ b/packages/docs/src/pages/docs/apis/restore-purchases.tsx @@ -37,9 +37,17 @@ function RestorePurchases() { > Apple docs - . Android: Calls queryPurchasesAsync for - both INAPP and SUBS. No system-level UI prompt - — Play has no concept of an explicit "restore" action.{' '} + . Android: Reads owned purchases from the store (Play:{' '} + queryPurchasesAsync; Amazon:{' '} + PurchasingService.getPurchaseUpdates) with no system-level + UI prompt. React Native, Expo, Flutter, MAUI, Godot, and KMP (Amazon, + Horizon, and community-provider builds) call the provider's + restore; KMP's Play build queries ownership directly. Play and + Amazon restores deliver nothing by themselves, while Horizon's + restore delivers each owned purchase to the purchase listeners (React + Native, Expo, Flutter, MAUI, and KMP). Godot emits no{' '} + purchase_updated signal; read owned purchases with{' '} + get_available_purchases_result.{' '} .

    +

    + When upgrading from an older major, see the{' '} + + restore upgrade notes + + . +

    diff --git a/packages/docs/src/pages/docs/errors.tsx b/packages/docs/src/pages/docs/errors.tsx index 2269296d7..5ddf9292f 100644 --- a/packages/docs/src/pages/docs/errors.tsx +++ b/packages/docs/src/pages/docs/errors.tsx @@ -256,6 +256,16 @@ public sealed record PurchaseError funds or offer ineligibility.
    +
    + Android provider codes: the wrappers forward the + provider's Android error code where they returned fixed codes; + handle specific codes such as not-prepared. See the{' '} + + error-code upgrade notes + + . +
    +

    Network & Service Errors

    diff --git a/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx b/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx index bd1a6f152..1e21fa93c 100644 --- a/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx +++ b/packages/docs/src/pages/docs/features/subscription-billing-issue.tsx @@ -276,9 +276,8 @@ kmpIapInstance.subscriptionBillingIssueListener endConnection(){' '} or app restart — a purchase that exits suspension and re-enters within - the same session will - not re-emit until the next reconnect or process - restart. + the same session will not re-emit until the next + reconnect or process restart.

    On iOS the StoreKit Message may be re-delivered by the system until diff --git a/packages/docs/src/pages/docs/foundation/research.tsx b/packages/docs/src/pages/docs/foundation/research.tsx index f57b2dbf6..1a82b0f82 100644 --- a/packages/docs/src/pages/docs/foundation/research.tsx +++ b/packages/docs/src/pages/docs/foundation/research.tsx @@ -67,7 +67,7 @@ const STUDY_GROUPS: StudyGroup[] = [ applied: ( <> The differential mode of the conformance runner, which runs adapters - side by side and reports divergences. It is part of suite 3.0.0 in + side by side and reports divergences. It is part of suite 4.0.0 in the repository and is not published to npm. ), @@ -94,7 +94,7 @@ const STUDY_GROUPS: StudyGroup[] = [ <> The metamorphic relation registry used to verify live store behavior — for example, a purchased item must appear in a following restore. - It is part of suite 3.0.0 in the repository and is not published to + It is part of suite 4.0.0 in the repository and is not published to npm. ), diff --git a/packages/docs/src/pages/docs/guides/StoreProviderDiagram.tsx b/packages/docs/src/pages/docs/guides/StoreProviderDiagram.tsx new file mode 100644 index 000000000..872e0d894 --- /dev/null +++ b/packages/docs/src/pages/docs/guides/StoreProviderDiagram.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { + ArrowRight, + ArrowDown, + Boxes, + Code, + Package, + Smartphone, + Store, +} from 'lucide-react'; +import { Link } from 'react-router-dom'; +import '../../../styles/store-providers.css'; + +const bindings = { + android: { + label: 'Android', + core: 'openiap-core', + discovery: 'Application manifest → factory', + installation: 'Maven artifact + openiapStore + openiapProvider', + vendor: 'Play, Amazon, Horizon, or a community SDK', + selection: '#select', + }, + apple: { + label: 'Apple', + core: 'OpenIAP', + discovery: 'Info.plist → Objective-C factory', + installation: 'Swift package, CocoaPod, or framework + factory key', + vendor: 'StoreKit, Onside, or a community SDK', + selection: '#apple-selection', + }, +} as const; + +const purchaseSteps = [ + { + title: 'Request', + detail: + 'The app calls requestPurchase with a store product id. The provider opens the store checkout.', + }, + { + title: 'Receive', + detail: + 'The provider maps the store callback to purchaseUpdated or purchaseError. Pending purchases grant no entitlement.', + }, + { + title: 'Verify and grant', + detail: + 'The app sends the receipt to a backend that supports this store. That backend verifies it and grants the entitlement.', + }, + { + title: 'Finish', + detail: + 'After successful delivery, the app calls finishTransaction. The provider acknowledges or consumes the store transaction.', + }, +] as const; + +export default function StoreProviderDiagram() { + const [platform, setPlatform] = useState('android'); + const binding = bindings[platform]; + const nodes = [ + { + title: 'Your app', + detail: 'Purchase UI and entitlement delivery', + icon: Smartphone, + }, + { + title: 'OpenIAP SDK', + detail: 'The same purchase APIs in all six frameworks', + icon: Code, + }, + { title: binding.core, detail: binding.discovery, icon: Boxes }, + { + title: 'Selected provider', + detail: 'Store-specific mapping and lifecycle', + icon: Package, + }, + { title: 'Store SDK', detail: binding.vendor, icon: Store }, + ]; + return ( +

    +
    +

    One contract, two native bindings

    +
    + {Object.entries(bindings).map(([key, value]) => ( + + ))} +
    +
    +
      + {nodes.map(({ title, detail, icon: Icon }, index) => ( +
    1. +
      +
      + {index < nodes.length - 1 && ( + + )} +
    2. + ))} +
    +

    + {binding.label} setup: {binding.installation}.{' '} + Select a provider. +

    +
    +

    A purchase crosses two boundaries

    +
      + {purchaseSteps.map(({ title, detail }, index) => ( +
    1. + +
      + {title} +

      {detail}

      +
      +
    2. + ))} +
    +

    + The store provider runs on the device. A{' '} + + Commerce Protocol provider + {' '} + runs on your backend. Selecting a store provider does not add receipt + verification support to a backend. +

    +
    +
    + ); +} diff --git a/packages/docs/src/pages/docs/guides/StoreProviderExample.tsx b/packages/docs/src/pages/docs/guides/StoreProviderExample.tsx new file mode 100644 index 000000000..680798118 --- /dev/null +++ b/packages/docs/src/pages/docs/guides/StoreProviderExample.tsx @@ -0,0 +1,186 @@ +import { ArrowUpRight, CheckCircle2, FlaskConical } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import CodeBlock from '../../../components/CodeBlock'; +import StoreProviderExampleGallery from './StoreProviderExampleGallery'; +import { repository, source, screens, files } from './StoreProviderExampleData'; +import '../../../styles/store-providers.css'; + +export default function StoreProviderExample() { + return ( +
    +
    +
    + + COMMUNITY EXAMPLE · FIREOS + +

    A community package example for provider authors

    +

    + Build a community provider outside OpenIAP, then run the official + Expo example against it. This repository adapts OpenIAP’s Amazon + implementation to test external packaging and integration; it is not + a second independently designed implementation. +

    +

    + For FireOS apps, use the{' '} + + official Amazon integration + {' '} + and its openiap-google-amazon artifact. This community + package is an educational example and is not recommended for + production purchases. +

    +
    + + Explore the repository +
    +
    + storeId: amazon-example + store: unknown + Community package · Amazon App Tester +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +

    + This is a dated validation snapshot, not certification.{' '} + Read the evidence and limits,{' '} + + machine-readable conformance report + {' '} + and CI runs. + The GitHub Packages example passed Expo registry consumer checks. The + README explains the pinned, unmerged OpenIAP inputs and other-framework + configuration; those checks do not prove registry parity across every + framework. +

    +

    Follow the implementation

    +
    + {files.map(({ icon: Icon, title, path, detail }) => ( + + + ))} +
    +

    Run the same comparison

    +
      +
    1. + Prepare public inputs. Clone the repositories beside + each other and follow the example README. The preparation script + builds Maven artifacts and packs the public Expo SDK from its pinned + input. +
    2. +
    3. + Compare the official screens. All Products, Purchase + Flow, Subscription Flow and Available Purchases use the same screen + code as the official Expo example. Offer Code and Alternative Billing + show this provider’s unsupported capability instead of starting a Play + flow. +
    4. +
    5. + Run Provider Acceptance. Load products → Purchase → + Restore / owned → Verify & finish. Compare the callback and + restored receipt before verification. Configure the development + backend and its publishable key first. +
    6. +
    7. + Try rejection and recovery. Cancel checkout, defer + approval, and retry an unfinished purchase after approval. No + entitlement or completion is allowed while pending or when + verification fails. Record builds, controlled tests and store testing + separately. +
    8. +
    +

    + Copied code and adaptations are listed in{' '} + + the provenance manifest + + . Showing a subscription screen does not establish a full subscription + lifecycle pass. This example is an implementation reference, not a + production payment app. +

    +
    + + Implementation brief for developers and AI coding agents + +

    + Use this brief with the contract and repository. It states the + integration boundary and acceptance criteria; a successful build alone + is insufficient. +

    + +
    +
    + ); +} diff --git a/packages/docs/src/pages/docs/guides/StoreProviderExampleData.ts b/packages/docs/src/pages/docs/guides/StoreProviderExampleData.ts new file mode 100644 index 000000000..f688f8158 --- /dev/null +++ b/packages/docs/src/pages/docs/guides/StoreProviderExampleData.ts @@ -0,0 +1,92 @@ +import { Code2, FlaskConical, Package, ShieldCheck } from 'lucide-react'; +export const repository = + 'https://github.com/hyodotdev/openiap-google-amazon-community'; +export const source = (path: string) => `${repository}/blob/main/${path}`; +export const examplePackage = { + name: '@hyodotdev/openiap-provider-amazon-example', + version: '0.0.1', +} as const; +export const screens = [ + { + id: 'home', + title: 'Same example, new provider', + image: 'home.png', + detail: + 'The official Expo example menus remain in place. Amazon Provider Lab changes the package selection and branding, so you can compare the same flows.', + }, + { + id: 'catalog', + title: 'Products from the Amazon SDK', + image: 'catalog.png', + detail: + 'The public fetchProducts API loads the App Tester catalog through the separately packaged provider.', + }, + { + id: 'acceptance', + title: 'Inspect the extension boundary', + image: 'acceptance.png', + detail: + 'Provider Acceptance checks connection, catalog, custom identity, receipt continuity, sandbox verification and completion. Pending or rejected purchases remain unfinished.', + }, + { + id: 'tutorial', + title: 'Inspect each integration layer', + image: 'tutorial.png', + wideImage: 'tutorial-wide.png', + detail: + 'Tap the app’s numbered layers to open a compact guide. Wide displays show the explanation beside the selected layer; both layouts share the same source data.', + }, + { + id: 'completed', + title: 'Verify, finish, read ownership', + image: 'completed.png', + detail: + 'An explicit Amazon verification request reaches the dev backend. Completion is shown only after a valid Sandbox response and an ownership read confirms consumption.', + }, +] as const; +export const files = [ + { + icon: Package, + title: 'Package the binding', + path: 'provider/build.gradle.kts', + detail: + 'Public core and Amazon SDK dependencies; your own Maven coordinates.', + }, + { + icon: Code2, + title: 'Implement the contract', + path: 'provider/src/main/kotlin/dev/openiap/provider/fireos/FireOsProviderFactory.kt', + detail: + 'A discoverable factory creates the provider and declares its identity and capabilities.', + }, + { + icon: ShieldCheck, + title: 'Wire startup and discovery', + path: 'provider/src/main/AndroidManifest.xml', + detail: + 'Factory metadata, early Amazon listener registration and protected response delivery.', + }, + { + icon: Code2, + title: 'Select it in the consumer', + path: 'example/app.config.js', + detail: + 'The public Expo config plugin selects amazon-example and its artifact; the app imports expo-iap.', + }, + { + icon: ShieldCheck, + title: 'Keep verification explicit', + path: 'example/src/utils/vegaRuntime.ts', + detail: + 'Map this known community receipt to Amazon; validate product, environment, state and storeId before finishing.', + }, + { + icon: FlaskConical, + title: 'Prove behavior and packaging', + path: 'VERIFICATION.md', + detail: + 'Public conformance, consumer tests, optimized builds and observed device results, with their limits.', + }, +]; + +export type ExampleScreen = (typeof screens)[number]; diff --git a/packages/docs/src/pages/docs/guides/StoreProviderExampleGallery.tsx b/packages/docs/src/pages/docs/guides/StoreProviderExampleGallery.tsx new file mode 100644 index 000000000..6b7394b35 --- /dev/null +++ b/packages/docs/src/pages/docs/guides/StoreProviderExampleGallery.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { screens, type ExampleScreen } from './StoreProviderExampleData'; + +function ScreenPreview({ screen }: { screen: ExampleScreen }) { + const wideNote = + 'wideImage' in screen + ? ' The wide app layout was checked with a temporary display-density override; original device settings were restored.' + : ''; + return ( +
    + + {'wideImage' in screen ? ( + + ) : null} + {`Amazon + +
    + {screen.detail} Actual Fire tablet capture, October 2, 2026. App Tester + simulates checkout.{wideNote} +
    +
    + ); +} + +function DesktopGallery() { + const [selected, setSelected] = useState(screens[0]); + return ( +
    +
    + {screens.map((screen, index) => ( + + ))} +
    + +
    + ); +} + +function MobileGallery() { + return ( +
    + {screens.map((screen, index) => ( +
    + + 0{index + 1} + {screen.title} + + +
    + ))} +
    + ); +} + +export default function StoreProviderExampleGallery() { + return ( + <> + + + + ); +} diff --git a/packages/docs/src/pages/docs/guides/ai-assistants.tsx b/packages/docs/src/pages/docs/guides/ai-assistants.tsx index 9899568e5..e744fc096 100644 --- a/packages/docs/src/pages/docs/guides/ai-assistants.tsx +++ b/packages/docs/src/pages/docs/guides/ai-assistants.tsx @@ -816,9 +816,8 @@ Desired outcome: [describe one thing your customer should be able to do]`}

    Errors exit 1; warnings and skipped checks can still - exit - 0. Read notCheckedLocally too. This is a - limited configuration check: it does not verify store accounts, + exit 0. Read notCheckedLocally too. This + is a limited configuration check: it does not verify store accounts, device purchases, backend behavior, or Commerce Protocol conformance. The four brief roles do not change its check scope. Use the sandbox checks above for the real diff --git a/packages/docs/src/pages/docs/guides/store-providers.tsx b/packages/docs/src/pages/docs/guides/store-providers.tsx new file mode 100644 index 000000000..8f887b703 --- /dev/null +++ b/packages/docs/src/pages/docs/guides/store-providers.tsx @@ -0,0 +1,728 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import DataTable, { type DataTableColumn } from '../../../components/DataTable'; +import SEO from '../../../components/SEO'; +import StoreProviderDiagram from './StoreProviderDiagram'; +import StoreProviderExample from './StoreProviderExample'; +import { examplePackage, repository } from './StoreProviderExampleData'; +import { LIBRARIES } from '../../../lib/images'; +import { OPENIAP_VERSIONS } from '../../../lib/versioning'; +import registry from '../../../generated/store-registry.json'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +interface ProviderListing extends Omit< + (typeof registry.stores)[number], + 'reports' +> { + reports: { platform: string; url: string }[]; +} +const STORE_LISTINGS: ProviderListing[] = registry.stores; + +const SELECTION: Record = { + 'react-native-iap': 'Set both properties in android/gradle.properties.', + 'expo-iap': + 'Set android.store and android.provider in the expo-iap config plugin, then prebuild.', + flutter_inapp_purchase: + 'Set both properties in android/gradle.properties. Keep openiapPlatform unset.', + 'kmp-iap': + 'Apply the OpenIAP Gradle plugin and select the provider platform variant.', + 'godot-iap': + 'Set openiap/android_store and openiap/android_provider in the Android export preset, then export Android.', + 'maui-iap': 'Set OpenIapStore and OpenIapProvider in the app project.', +}; + +interface SdkSelection { + name: string; + displayName: string; + setupPath: string; + configuration: string; +} + +const SDK_SELECTIONS: SdkSelection[] = LIBRARIES.map((library) => ({ + ...library, + configuration: SELECTION[library.name], +})); +const SDK_COLUMNS: DataTableColumn[] = [ + { + header: 'SDK', + cell: (row) => {row.displayName}, + }, + { header: 'Configuration', cell: (row) => row.configuration }, +]; +const STORE_COLUMNS: DataTableColumn[] = [ + { + header: 'Store', + cell: (store) => ( + <> + {store.displayName} +

    + {store.maintainers.join(', ')} +
    + + ), + }, + { header: 'Id', cell: (store) => {store.id} }, + { + header: 'Status', + cell: (store) => ( + <> + {store.status} + {store.reports.map((report) => ( + + ))} + + ), + }, + { header: 'Provider', cell: (store) => {store.coordinates} }, +]; + +export default function StoreProviders() { + useScrollToHash(); + return ( +
    + +

    Community store providers

    +

    + Use an independently maintained Apple or Android store with any OpenIAP + SDK. Both platform bindings follow the same provider contract. + Registration is optional. Purchase APIs stay the same. +

    +

    + A provider must already implement the store’s billing SDK. OpenIAP ships + official Apple App Store, Play, Horizon, and Amazon providers. +

    +
    + + How providers fit together + + +
    +
    + + Try a real Amazon SDK binding + + +
    +
    + + Add a community package to an existing Expo app + +

    + Keep expo-iap and your purchase screens. Add the + provider’s native package and configure the Android build to select + it. This walkthrough uses the{' '} + FireOS community example; its repository name + differs from its installation name, {examplePackage.name} + . For a maintained FireOS app, use the{' '} + official Amazon integration + . +

    +
      +
    1. + Use matching provider and SDK builds. Version{' '} + {examplePackage.version} is a pre-merge validation snapshot. It + requires the pinned Client Protocol 0.2 SDK and core inputs; follow + the README’s{' '} + + pinned public Expo and core inputs + {' '} + first. Then, in your existing app, install the SDK tarball named in + the prepared example’s expo-iap dependency: + + Replace expo-iap-HASH.tgz with the actual tarball + filename from example/package.json. Preparation only + updates the example app; it does not upgrade your existing app. + Check the{' '} + + package’s peer dependencies + {' '} + for compatible Expo and build-properties versions. A new core major + or an SDK outside that range requires a rebuilt provider and fresh + conformance and consumer checks; the snapshot does not claim + compatibility with the next release train. +
    2. +
    3. + Install from GitHub Packages. Configure an ignored{' '} + .npmrc and a package-manager token with{' '} + read:packages. Keep the token outside the app and Expo + public environment variables. + + + This command pins the{' '} + + verified example distribution + + , rather than installing from npmjs.org. +
    4. +
    5. + Select one IAP plugin per build. Keep the{' '} + expo-iap dependency and imports. Remove its existing + plugin entry, put your current IAP options in one object, and add + the selected entry in app.config.js. Keep your other + plugins and app settings. +

      + Move store selection out of the shared options: remove old{' '} + android.store, android.provider,{' '} + modules.horizon, modules.amazon.fireOS,{' '} + modules.amazon.vegaOS and{' '} + android.amazon.vegaOS. Unset the old{' '} + EXPO_IAP_FIREOS, EXPO_IAP_HORIZON and{' '} + EXPO_IAP_VEGA environment flags. Preserve unrelated + options, including your Appstore public-key path. +

      + ({\n ...config,\n plugins: [\n ...(config.plugins ?? []),\n community\n ? ['${examplePackage.name}', {\n ...iapOptions,\n openIapRepository: '/absolute/path/to/openiap-google-amazon-community/.local/maven',\n }]\n : ['expo-iap', {\n ...iapOptions,\n android: { ...iapOptions.android, store: 'play' },\n enableLocalDev: true,\n localPath: { android: '/absolute/path/to/openiap/packages/google' },\n }],\n ],\n});`} + /> + Use the same store setting in your EAS profiles: + + For this snapshot, use EAS --local with the prepared + inputs. The Play branch needs matching native sources; set{' '} + localPath.android to packages/google in + the same compatible OpenIAP checkout that prepared your Expo SDK. + Once compatible SDK and native Play releases exist, remove{' '} + localPath and set enableLocalDev: false to + use the published packages. Remote workers need the prepared inputs + and their own paths; local absolute paths are not uploaded + automatically. +

      + The community branch uses the installed provider and public core + Maven artifacts, without native source includes. Its required{' '} + openIapRepository points to the prepared core Maven + artifacts; replace the placeholder with your absolute path. The + plugin adds its bundled provider repository and uses the installed + package’s native version. Keep the existing application id and + registered catalog; retain android.amazon.appstoreKey{' '} + for Appstore builds. See the{' '} + + installation guide + + . +

      +
    6. +
    7. + Rebuild the native Android app. Expo Go and a + JavaScript reload cannot install a native provider. + + Use google for a local Play build. Rerun prebuild and + rebuild after switching the profile or updating the provider. +
    8. +
    9. + Check the selected provider. Your existing{' '} + useIAP, product, purchase, restore and finish APIs stay + the same. Purchases from this example carry{' '} + store: 'unknown' and{' '} + storeId: 'amazon-example'. Configure the backend for + Amazon receipt verification, grant entitlement only after valid + verification, then finish. The native package does not install a + server verification adapter. Follow the{' '} + + verification flow + {' '} + and{' '} + + observed limits + + . +
    10. +
    + + Choosing a Samsung, Huawei, or Xiaomi provider + +

    + We welcome community providers for stores such as Samsung, Huawei, and + Xiaomi. OpenIAP does not yet supply or certify packages for these + stores. Before installing a provider, expect its maintainer to + publish: +

    +
      +
    • + A native adapter for that store’s billing SDK, supported platforms + and compatible OpenIAP core, Client Protocol and framework versions. +
    • +
    • + A package name, fixed version, stable storeId, native + Maven coordinates and repository, plus Expo plugin instructions or + the store/provider configuration. +
    • +
    • + Store-specific application setup, catalog and authenticated backend + verification instructions. A config plugin alone is not a billing + implementation. +
    • +
    • + A public conformance report and store test evidence, with supported + capabilities and unverified purchase or subscription scenarios. +
    • +
    +

    + Expect the same OpenIAP purchase APIs and events after native setup, + with that provider’s storeId and capability behavior. One + Android AAR can serve compatible framework SDKs; each app still needs + its own configuration and rebuild. The Expo plugin configures Expo + only, and Apple needs a separate Swift adapter. +

    +
    +
    + + Select an Apple provider + +

    + Link the provider’s Swift package, CocoaPod, or framework in your app + and set this Info.plist key to its Objective-C factory class name. + This applies to native apps and all six framework SDKs; their purchase + APIs stay the same. +

    +

    + Reuse one Swift provider across compatible Apple framework SDKs. + Android needs a separate native adapter; an Android AAR cannot run on + Apple platforms. Each app must link and select its platform’s + provider. +

    + dev.hyo.openiap.PROVIDER\nYourStoreProviderFactory' + } + /> +

    + Keep the provider class linked in Release builds. For a static + provider, use the maintainer’s linker settings and retain a direct + class reference in app startup, for example{' '} + NSStringFromClass(YourStoreProviderFactory.self). Verify + discovery in an optimized app. An Info.plist string alone does not + link a binary. +

    +

    + Without this key, OpenIAP uses Apple App Store. An invalid explicit + selection fails at connection; it never falls back to StoreKit. In + Expo, explicit provider configuration takes precedence over automatic + Onside detection. Existing Onside configuration remains supported. +

    +
    +
    + + Select an Android provider + +

    + Use the community id and fixed Maven coordinates supplied by the + provider maintainer; official store ids and their aliases are rejected + with provider coordinates. Add its Maven repository to your app if it + is hosted outside Maven Central. Select one provider per Android + build. +

    +

    + Implement the store SDK once in a native Android provider AAR. A + community author can use samsung, huawei, or{' '} + xiaomi as its stable storeId, with an + implementation of that store's SDK. Compatible framework SDKs + dispatch their existing purchase APIs and events through that + contract; the provider author does not write a billing adapter for + each framework. Each app still needs the native dependency, selection + settings and a rebuild. An Expo config plugin does not configure the + other frameworks. +

    + + row.name} + /> + + Expo + + + + + Kotlin Multiplatform + +

    + The neutral provider variant links core and your + provider. Apply the resolver plugin in the consuming Android app so it + adds the provider dependency; a published library’s build script does + not run in its consumer. +

    + +

    + Set the two Gradle properties above. If your app declares a{' '} + platform flavor dimension, add a provider{' '} + flavor instead of a missing-dimension strategy. +

    + + .NET MAUI + + + your-store + com.example:openiap-your-store:1.0.0 +`} + /> +

    + MAUI resolves the provider’s Maven runtime dependencies using the + included Gradle wrapper and your Android Java SDK. NuGet owns its + existing runtimes; a provider requiring newer versions reports a + conflict so you can update those NuGet packages. +

    +

    For a provider outside Maven Central, add its repository:

    + + +`} + /> +

    + Store-specific app ids, keys, and resources belong to the provider’s + manifest or resources. Follow its setup instructions. Existing + official aliases, legacy flags, and automatic device selection keep + working; an external provider requires an explicit pair. Invalid or + incomplete selection fails the Android build, including Godot exports. +

    +
    +
    + + Preserve store identity + +

    + IapStore has a fixed set of values. New stores use{' '} + store = 'unknown' and their own nonempty{' '} + storeId. A provider that serves an existing store reports + that store's canonical storeId and legacy{' '} + store value in its purchases, independent of the + community id used to select it. Official ids are apple,{' '} + play, horizon, and amazon; + Play’s existing enum wire value remains 'google'. +

    +

    + Use purchase.storeId when routing a purchase to your + backend, and preserve it when finishing or verifying a purchase. + Registered ids have generated StoreIds constants. Include{' '} + storeId when creating purchase or verification result + objects manually. Previously saved official purchases without this + field decode to their official id; community purchases require a valid + explicit id. +

    +

    + Purchase fields ·{' '} + + Verification result fields + + . Use the provider's authenticated server integration for receipt + verification. +

    +

    + When upgrading from an older major, see the{' '} + + storeId upgrade notes + + . +

    +
    +
    + + Build an Apple provider + +

    + Implement OpenIapModuleProtocol in your own repository + and publish a factory with a public no-argument initializer. Depend on + the public OpenIAP product and your store SDK. Return + your backend from create(); the app’s{' '} + OpenIapModule facade owns selection. +

    + { ["pendingPurchases"] } + public func create() throws -> any OpenIapModuleProtocol { YourStoreModule() } +}`} + /> +

    + A new store returns PurchaseIOS with{' '} + store = .unknown and its custom storeId. An + App Store adapter uses store = .apple and{' '} + storeId = "apple". Use request.apple for + Apple-platform purchase arguments, including community stores. + Preserve opaque transaction IDs and receipts through listeners, + ownership reads, and completion. Optional StoreKit-only methods + default to feature-not-supported. +

    +

    + On either platform, a failed requestPurchase emits one + canonical purchase-error event before returning an empty result or + throwing, and delivers no purchase. Event-based SDK callers depend on + this behavior; conformance rejects missing or duplicate errors and + contradictory purchase events. +

    +

    + Both factories expose the generated{' '} + StoreProviderDescriptor: store identity, platform, native + core version, Client Protocol version, and capability ids. Native + builds require the same stable core major and a runtime at least as + new as the declared build. Prerelease versions require an exact match. + Before Client Protocol 1.0, providers must also match its minor + version. Declare the versions used to build the provider. +

    +

    + Consume the OpenIapConformance Swift product in your test + target. Implement ProviderConformanceAdapter with your + real error and entitlement mappers and sandbox capability triggers. + Run ProviderConformanceSuite(adapter: adapter).run(), + assert report.conformant, and write the Codable report. + The apple-provider and android-provider{' '} + profiles share lifecycle, identity, error, ownership and capability + requirements. Apple App Store advertises only capabilities supported + by the current platform and OS version. +

    +

    + Start from the{' '} + + independent Swift fixture + + . It covers public imports, full completion payloads, listener + cleanup, failing capability assertions, and metadata discovery in a + Release consumer. +

    +
    +
    + + Build an Android provider + +

    + Create an Android library in your own repository. Depend only on the + public core contract and your store SDK. +

    + + + + +`} + /> +

    + The factory needs a public no-argument constructor. Core supplies the + R8 consumer keep rule and discovers classes in any namespace. + Conflicting factory metadata fails manifest merging; do not override + it with tools:replace. +

    +

    + Choose a stable lowercase id containing letters, digits, and single + dot, underscore, or hyphen separators. A new store uses a custom id + with store: 'unknown'; avoid registry alias collisions. + Build selection requires a community id: official ids and their + aliases are rejected with provider coordinates. The runtime only + rejects auto, none, apple,{' '} + google, and unknown, so a provider that + serves an existing store reports that store's canonical{' '} + storeId and legacy store value in its + purchases. Store identity does not replace backend receipt + verification. +

    +

    + OpenIapProtocol and its referenced types are public API + under SemVer. Future protocol members require default implementations. + A stable provider can run on an equal or newer core within its major; + prerelease cores require an exact match. Invalid factories and + incompatible versions produce a typed{' '} + OpenIapError.ProviderConfiguration developer error. +

    +

    + Implement the generated handlers and listeners, emit normalized + errors, and stamp both identity fields on every purchase and + verification result. Declare only capabilities you implement:{' '} + pendingPurchases, subscriptionBillingIssue, + and offerCodeRedemption. Unsupported operations must + return a documented unsupported result or{' '} + FeatureNotSupported. Receipt verification preserves + normalized provider error codes, including unsupported receipt + verification. Handle that code by using the store’s supported server + integration; a verification transport error does not invalidate the + purchase. On failure, Godot plain verification returns null and emits{' '} + purchase_error, while managed verification returns the + code in errors. The deprecated product-type filtered read + has an unsupported default; prefer getAvailablePurchases. +

    +

    + An owned subscription does not establish automatic renewal. Leave{' '} + autoRenewingAndroid null when the store cannot report it, + and set the required compatibility hint isAutoRenewing to + false. Use server verification for entitlement and expiry. +

    +
    +
    + + Run conformance in your CI + + +

    + Extend ProviderConformanceSuite with your factory, a + fresh provider, and a StoreConformanceAdapter bound to + your production error and entitlement mappers. Supply a sandbox SKU + and an isolated test account. Implement triggerCapability{' '} + to drive each declared capability after the suite attaches listeners.{' '} + Supply redemptionActivity from your host or Robolectric + test in both cases: when offerCodeRedemption is declared + the suite opens the real flow with that activity, and when it is + undeclared the suite calls the provider with that activity and asserts + the documented no-op. A missing provider or activity fails the run + with a message naming it. +

    + ().configureEach { + systemProperty("openiap.conformanceReport", + layout.buildDirectory.file("reports/openiap/{storeId}.json").get().asFile.path) +}`} + /> +

    + The report records executed results, suiteVersion,{' '} + clientProtocolVersion, capabilities, and the required + behavior scope. Missing, skipped required, or failed behaviors cannot + pass. Declared capabilities add runtime checks for pending purchases, + suspended subscriptions, and redemption events. +

    +

    + The android-provider profile covers Android product, + purchase, ownership, completion, identity, errors, and declared + capabilities. It is narrower than the full server-lifecycle suite. An{' '} + android-mapping report alone cannot promote a community + provider. +

    +

    + Start from the{' '} + + independent fixture + + . It consumes local Maven artifacts and includes a failing-capability + check, a minified host, and a duplicate-provider build check. +

    +
    +
    + + Register and maintain a provider + +

    + Optional registration adds aliases, generated constants, and a + listing. Submit an entry in{' '} + + store-registry.json + {' '} + with id, platform, display name, tier, maintainers, repository, fixed + coordinates, capabilities, and the latest report’s public URL and + JSON. Apple coordinates specify a Swift package URL, product, and + exact version. A store supporting both platforms uses one id and a + bindings object; each binding owns its coordinates, capabilities, and + report. Each non-official binding’s report must carry the entry’s id + as storeId with store unknown, match its + declared capabilities, and cover the profile’s required behaviors. +

    + +

    + Experimental entries have an unverified or failing + platform binding. Community entries carry passing + reports for every platform binding. Official stores + live in this monorepo and keep the existing capability matrix. + Promotion to official requires a maintainer decision, vendor and + maintenance review, and SDK parity verification. +

    +

    + After adoption of a new suite major, an older passing report is marked + outdated. CI marks it unmaintained after{' '} + {registry.maintenanceWindowDays} days. The current major adoption date + is {registry.suiteMajorAdoptionDate}; update the report to restore + current community status. +

    + row.id} + /> +
    +
    + ); +} diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index eacde1707..cb8d25c76 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -123,6 +123,7 @@ import Releases from './updates/releases'; import Versions from './updates/versions'; import AIAssistants from './guides/ai-assistants'; import Testing from './guides/testing'; +import StoreProviders from './guides/store-providers'; import SecurityOverview from './security/overview'; import SecuritySbom from './security/sbom'; import SecurityCompliance from './security/compliance'; @@ -604,6 +605,15 @@ function Docs() { AI Assistants +
  • + (isActive ? 'active' : '')} + onClick={closeSidebar} + > + Store Providers + +
  • } /> + } /> + } /> } /> } /> } /> diff --git a/packages/docs/src/pages/docs/lifecycle/subscription.tsx b/packages/docs/src/pages/docs/lifecycle/subscription.tsx index 419323b7e..acd9e6b1e 100644 --- a/packages/docs/src/pages/docs/lifecycle/subscription.tsx +++ b/packages/docs/src/pages/docs/lifecycle/subscription.tsx @@ -60,6 +60,13 @@ function Subscription() {

    +

    + Android examples on this page describe Google Play. Community providers + expose only the lifecycle data their store supplies and need that + store's server verification. Installing a provider does not add its + backend support to IAPKit. +

    +
    Platform Comparison @@ -87,7 +94,7 @@ function Subscription() { ✅ willAutoRenew
  • @@ -165,12 +172,15 @@ function Subscription() {

    Android: client-side{' '} subscription lifecycle data is limited to{' '} - isAutoRenewing, isSuspendedAndroid, and{' '} - pendingPurchaseUpdateAndroid — the purchase itself + autoRenewingAndroid, isSuspendedAndroid, + and pendingPurchaseUpdateAndroid — the purchase itself still carries productId, purchaseToken,{' '} transactionDate, and purchaseState. Expiry and renewal dates and detailed subscription state require{' '} - server-side validation. + server-side validation. Store support varies; null + renewal status means unknown, not cancelled. The required{' '} + isAutoRenewing compatibility hint cannot represent + unknown.

    Both platforms: Use{' '} @@ -558,7 +568,8 @@ function Subscription() { android: (

    - Detection: isAutoRenewing = false + Renewal disabled: autoRenewingAndroid === + false; null means unknown
    User still has access: @@ -662,7 +673,8 @@ function Subscription() {
    • iOS: willAutoRenew = false
    - • Android: isAutoRenewing = false + • Android: autoRenewingAndroid === false when reported; null means + unknown
    @@ -773,7 +785,7 @@ function Subscription() { after validation and content delivery.
  • - Android 3-day window: Android purchases must be + Google Play 3-day window: Play purchases must be acknowledged within 3 days or they're automatically refunded.
  • @@ -1075,8 +1087,9 @@ function Subscription() { validation (key for all server API calls)
  • - isAutoRenewing: Whether the subscription - will auto-renew + autoRenewingAndroid: Store-reported renewal + status; null when unavailable. It does not prove entitlement + or cancellation.
  • isSuspendedAndroid: Whether the @@ -1360,10 +1373,13 @@ function Subscription() { Cancellations

    - Client-side, only isAutoRenewing === false is - available. For accurate cancellation details (when, why, by - whom), use the server API which provides{' '} - canceledStateContext. + Google Play can report renewal disabled through{' '} + autoRenewingAndroid === false. A null value means + unknown; do not infer cancellation from the legacy{' '} + isAutoRenewing hint. For cancellation details + (when, why, by whom), use the Google Play server API's{' '} + canceledStateContext. Other stores require their + own server verification.

    @@ -1447,9 +1463,11 @@ function Subscription() {
  • + + + + @@ -198,7 +215,11 @@ function Purchase() { - + - + + + + + + diff --git a/packages/docs/src/pages/docs/updates/migration.tsx b/packages/docs/src/pages/docs/updates/migration.tsx index eff1ee856..2f7badb65 100644 --- a/packages/docs/src/pages/docs/updates/migration.tsx +++ b/packages/docs/src/pages/docs/updates/migration.tsx @@ -1,6 +1,8 @@ import { Link } from 'react-router-dom'; import AnchorLink from '../../../components/AnchorLink'; import Callout from '../../../components/Callout'; +import CodeBlock from '../../../components/CodeBlock'; +import LanguageTabs from '../../../components/LanguageTabs'; import SEO from '../../../components/SEO'; import { LIBRARIES } from '../../../lib/images'; @@ -221,7 +223,7 @@ const flutterCustomWireMigrations = [ const scheduledRemovals = [ { title: - 'react-native-iap, expo-iap, flutter_inapp_purchase, and the openiap-google Gradle plugin', + 'Removal in openiap-google 5.0, react-native-iap 18.0, expo-iap 7.0, and flutter_inapp_purchase 12.0', rows: [ [ 'horizonEnabled=true', @@ -231,7 +233,7 @@ const scheduledRemovals = [ ], }, { - title: 'expo-iap', + title: 'Removal in expo-iap 7.0', rows: [ [ 'modules.horizon / EXPO_IAP_HORIZON=1', @@ -249,11 +251,11 @@ const scheduledRemovals = [ ], }, { - title: 'OpenIap.Maui', + title: 'Removal in OpenIap.Maui 4.0', rows: [['OpenIapAndroidStore', 'OpenIapStore']], }, { - title: 'react-native-iap', + title: 'Removal in react-native-iap 18.0', rows: [ [ 'NitroProduct.originalPriceAndroid, originalPriceAmountMicrosAndroid, introductoryPriceValueAndroid, introductoryPriceCyclesAndroid, introductoryPricePeriodAndroid, subscriptionPeriodAndroid, freeTrialPeriodAndroid', @@ -545,14 +547,13 @@ function Migration() {
    - Scheduled for the next major release + Scheduled removals

    - These keys and fields are deprecated. Every patch and minor release - keeps them working, and the store keys print a build warning. Each - goes in the next major release of the package that carries it; the - Gradle store keys, which several packages read, go in one major - release of all of them. The store rule itself is in{' '} + These deprecated keys and fields remain available in the community + provider release train. Their removal moves to the versions below; + shared Gradle flags are removed together across the packages that read + them. Migrate using{' '} How the Store Is Selected @@ -583,6 +584,483 @@ function Migration() { ))}

    +
    + + Provider contract upgrade (October 2026) + +

    + The October 2026 train adds community store providers. Each change + below says who is affected and what to do; upgrade native and + framework dependencies together (the{' '} + + release card + {' '} + lists the versions). +

    + + + Purchases and verification results require storeId + +

    + What changed: storeId is required at + construction on Purchase, PurchaseInput, and{' '} + RequestVerifyPurchaseWithIapkitResult in TypeScript, + Dart, Kotlin purchases, and the new Swift public initializers; C# and + the Kotlin IAPKit result infer official ids instead. A community + purchase uses store unknown with its own provider id; see{' '} + + Preserve store identity + {' '} + for the full rule. The unknown store spells{' '} + IapStore.Unknown in Dart, Kotlin, and C#,{' '} + IapStore.unknown in Swift, IapStore.UNKNOWN{' '} + in GDScript, and 'unknown' in TypeScript. +

    +

    + Who is affected: apps that hand-build purchase,{' '} + PurchaseInput, or verification values (fixtures, mocks, + custom adapters, server-driven finish flows), and custom types that + implement PurchaseCommon, which must add{' '} + storeId. Passing SDK-returned purchases through needs no + change. Swift apps are affected only through decoding and custom{' '} + PurchaseCommon conformers: base had no public purchase + initializer. +

    +

    + What to do: add storeId when + constructing the value: +

    +
      +
    • + Saved official JSON without it still decodes to the canonical id, + but a community value needs an explicit valid id. +
    • +
    • + A mismatched or invalid id fails to decode: TypeScript, Dart, + Kotlin, and Swift decoders throw, GDScript from_dict{' '} + returns null (null-check decoded values), and C# deserialization + throws JsonException. A hand-built C# record with a + missing community id or a mismatched id throws whenever{' '} + StoreId is read, including Equals,{' '} + GetHashCode, and serialization. +
    • +
    • + store_id defaults to "" in + GDScript. from_dict rejects a blank id for every store, + so a hand-built object round-tripped outside{' '} + finish_transaction decodes to null. The Godot finish + helpers stamp the connected provider's id onto blank finish + inputs (a blank id for an official store is stripped instead), and + GDScript PurchaseInput has no store_id. +
    • +
    + + {{ + typescript: ( + {`import type { PurchaseAndroid } from 'react-native-iap'; + +const purchase: PurchaseAndroid = { + ...savedPurchase, // your stored purchase + store: 'google', + storeId: 'play', +};`} + ), + dart: ( + {`final purchase = PurchaseAndroid( + // ...existing fields, + store: IapStore.Google, + storeId: 'play', +);`} + ), + kotlin: ( + {`val purchase = PurchaseAndroid( + // ...existing fields, + store = IapStore.Google, + storeId = "play", +)`} + ), + csharp: ( + {`var purchase = new PurchaseAndroid +{ + // ...existing fields, + Store = IapStore.Google, + StoreId = "play", +};`} + ), + gdscript: ( + {`const Types = preload("res://addons/godot-iap/types.gd") + +var purchase = Types.PurchaseAndroid.new() +purchase.store = Types.IapStore.GOOGLE +purchase.store_id = "play"`} + ), + }} + +

    + An iOS purchase uses store apple with{' '} + storeId apple. +

    + + + KMP Store gains UNKNOWN + +

    + What changed: the public Store enum has + a new UNKNOWN case, returned for community-provider + builds (the Android provider build and any non-Apple iOS + provider) and on iOS when the Apple provider selection fails. +

    +

    + Who is affected: exhaustive{' '} + when (getStore()) expressions without an{' '} + else branch, which no longer compile. +

    +

    + What to do: add the UNKNOWN branch. +

    + {`// kmpIAP is your KmpInAppPurchase instance. +when (kmpIAP.getStore()) { + Store.PLAY_STORE -> TODO("Play") + Store.AMAZON -> TODO("Amazon") + Store.APP_STORE -> TODO("App Store") + Store.HORIZON -> TODO("Horizon") + Store.UNKNOWN -> TODO("community provider") + // NONE is never returned; it only completes the exhaustive when. + Store.NONE -> TODO("no store") +}`} + + + Kotlin IAPKit result is no longer a data class + +

    + What changed:{' '} + RequestVerifyPurchaseWithIapkitResult is now a plain + class with a private primary constructor. The old 3-, 5-, and + 6-argument constructors keep their signatures, but copy{' '} + lost binary compatibility; component1-3 still exist, and{' '} + equals/hashCode now compare all seven fields + instead of three. PurchaseAndroid gains a required{' '} + storeId parameter after store (after{' '} + storefrontCountryCodeIOS on PurchaseIOS), so + their constructor and copy signatures change and later{' '} + componentN indexes shift. +

    +

    + Who is affected: Kotlin callers on OpenIAP Google + 4.0.0 and KMP 4.0.0 that construct or copy purchases or results, or + decode saved JSON. +

    +

    + What to do: recompile against 4.0.0 and pass{' '} + storeId to purchase constructors. The result's{' '} + copy keeps storeId while store{' '} + is unchanged (a new store resets it to the official id, or throws for{' '} + Unknown without storeId) and now preserves{' '} + clientPayload, productId, and{' '} + environment. A result built with{' '} + IapStore.Unknown and no storeId still + compiles but throws IllegalArgumentException. A + purchase's copy keeps its storeId, so + pass a matching storeId when you change{' '} + store. JSON with a missing or unrecognized{' '} + store value used to decode as Unknown and now throws + unless it carries a valid community storeId. +

    + + + Shared classes move to openiap-core + +

    + What changed: the shared dev.hyo.openiap{' '} + classes moved into the new openiap-core artifact, which + the store artifacts depend on. +

    +

    + Who is affected: apps that link the{' '} + openiap-google AAR by file. +

    +

    + What to do: add openiap-core next to it; + Maven consumers get it transitively. +

    + + + Apple requires tvOS 16 + +

    + What changed: the SwiftPM floor moves from tvOS 15 to + tvOS 16. The CocoaPods deployment target already required 16.0. +

    +

    + Who is affected: tvOS apps on OpenIAP Apple 4.0.0 + that build through SwiftPM with a 15.x deployment target. +

    +

    + What to do: raise the tvOS deployment target to 16.0. +

    + + + Cancelled Apple operations throw CancellationError + +

    + What changed: cancelling the calling task of{' '} + requestPurchase or another StoreKit operation now throws{' '} + CancellationError with no purchase-error event. + Previously requestPurchase, fetchProducts,{' '} + getPromotedProductIOS, and{' '} + subscriptionStatusIOS wrapped the cancellation into an + emitted PurchaseError, and syncIOS,{' '} + restorePurchases, and openRedeemOfferCode{' '} + threw it as a PurchaseError without an event.{' '} + initConnection still returns false when{' '} + endConnection interrupts its init task. The SwiftUI + store's getAvailablePurchases and{' '} + getActiveSubscriptions also throw{' '} + CancellationError, without updating state, when the task + is cancelled or endConnection completes while they wait; + its initConnection throws it when its task is cancelled, + even after connecting. +

    +

    + Who is affected: Swift apps that call OpenIAP from a + task that can be cancelled, such as a SwiftUI .task. +

    +

    + What to do: catch CancellationError at + the call site alongside the existing PurchaseError path; + keep using the listener for real purchase failures. A user dismissing + the purchase sheet still reports user-cancelled through + the listener. +

    + + + Flutter initConnection returns the native result on Apple + +

    + What changed: on iOS and macOS{' '} + initConnection() now returns the native Boolean, false + when StoreKit cannot make payments; the plugins returned nil (read as + true) before. +

    +

    + Who is affected: Flutter apps that treat the Apple + init result as always true. +

    +

    + What to do: branch on the result and show the store + as unavailable when it is false. +

    + + + Android errors carry the provider's code + +

    + What changed: the wrappers forward the + provider's Android error code where they returned fixed codes: +

    +
      +
    • + React Native: calls before init already reported{' '} + not-prepared; thrown init errors,{' '} + verifyPurchase, and{' '} + verifyPurchaseWithProvider now forward the + provider's code, and deep-link and endConnection{' '} + errors arrive as OpenIAP errors. +
    • +
    • + Expo: Android calls that rejected every failure with{' '} + service-error now forward the provider's code ( + not-prepared before init on Play and Horizon; Amazon + queries work before init); thrown init errors and verification + forward it too. +
    • +
    • + Flutter: calls that answered native failures with{' '} + service-error now forward the provider's code + (Dart-guarded calls still throw not-prepared before + init), and thrown init errors forward it instead of{' '} + init-connection. +
    • +
    • + Godot: failure results now carry a code field (the + provider's code, else service-error, or{' '} + purchase-verification-failed for verification);{' '} + verify_purchase_with_provider before init now reports{' '} + not-prepared, as restore_purchases and the + entitlement reads already did; init_connection still + returns only a Boolean. +
    • +
    +

    + On Amazon, getStorefront and IAPKit verification without + a userId now throw StoreConnectionFailure instead of{' '} + InitConnection when the user-data request cannot start + (code still init-connection; initConnection{' '} + still returns false), so Kotlin callers matching by type must handle + the new class. +

    +

    + Who is affected: apps that match literal Android + error codes or messages. +

    +

    + What to do: handle specific codes such as{' '} + not-prepared. +

    + {`import { + ErrorCode, + getAvailablePurchases, + initConnection, +} from 'expo-iap'; +import type { PurchaseError } from 'expo-iap'; + +async function loadPurchases() { + try { + return await getAvailablePurchases(); + } catch (error) { + // Before: expo-iap rejected this Android call with service-error. + if ((error as PurchaseError).code !== ErrorCode.NotPrepared) throw error; + const connected = await initConnection(); + if (!connected) throw error; + return getAvailablePurchases(); + } +}`} + + + Failed verification reports the native code + +

    + What changed: Flutter reports the native error code + from verifyPurchase and{' '} + verifyPurchaseWithProvider on every platform instead of + always purchase-verification-failed; KMP does the same + for verify-with-provider on iOS, and Godot on iOS and Android. Generic + Android failures in Flutter now report{' '} + transaction-validation-failed. +

    +

    + Who is affected: apps that match{' '} + purchase-verification-failed from these calls. +

    +

    + What to do: match the specific codes. +

    + + + Android restore runs the provider first + +

    + What changed: React Native, Expo, and Flutter run the + provider's Android restore before querying ownership. On Horizon + each owned purchase then reaches the purchase listeners; Play and + Amazon restores deliver nothing by themselves. Godot runs the same + restore but emits no purchase_updated signal on Horizon, + and a failed restore still emits purchase_error: read + owned purchases with get_available_purchases_result. + Flutter restore failures on iOS and macOS now read{' '} + Failed to restore purchases [code]: ... instead of{' '} + Failed to sync iOS purchases [code]: ... (the code still + forwards the native code). +

    +

    + Who is affected: Horizon apps with purchase + listeners, and Flutter apps that match the Apple restore failure + message. +

    +

    + What to do: make the purchase handler idempotent so a + repeated delivery grants once, and keep reading owned purchases after + the restore resolves. +

    + {`import { purchaseUpdatedListener } from 'react-native-iap'; + +// Horizon redelivers every owned purchase on restore; grantOnce is your code +// and must grant at most once per token against your stored entitlements. +purchaseUpdatedListener((purchase) => { + if (purchase.purchaseState !== 'purchased') return; + void grantOnce(purchase.purchaseToken ?? purchase.id, purchase.productId); +});`} + + + Godot purchase_error events change + +

    + What changed: a failed Apple restore now emits one{' '} + purchase_error carrying OpenIAP's code instead of + two (sync-error then service-error);{' '} + verify_purchase now emits purchase_error on + failure instead of returning null silently (on Apple every failure, on + Android when the native result carries a code). On Apple,{' '} + get_storefront failures, the{' '} + products_fetched failure payloads for{' '} + fetch_products, and the failure results of the iOS-only + methods now carry OpenIAP's code instead of{' '} + service-error or no code. +

    +

    + Who is affected: Godot apps that count restore + errors, call verify_purchase or{' '} + get_storefront, or read failure codes from{' '} + products_fetched. +

    +

    + What to do: handle one restore event, null-check the + verify result alongside the signal, and match specific codes. +

    + + + Godot export rejects an unknown Android store + +

    + What changed: an unrecognized{' '} + openiap/android_store value now fails the export instead + of falling back to Play. +

    +

    + Who is affected: Godot Android builds with a + misspelled store value. +

    +

    + What to do: fix the store value. +

    + + + iOS subscription checks use the active flag + +

    + What changed: hasActiveSubscriptions on + iOS now answers from OpenIAP's active flag (expiration in the + future): React Native and Flutter on every call (Flutter on iOS and + macOS), KMP and MAUI on id-filtered calls (MAUI also on Mac Catalyst), + as Expo, Godot, and native already did. A subscriber in billing grace + reads inactive. Flutter hasActiveSubscriptions no longer + checks init in Dart: before initConnection, iOS and macOS + connect and answer instead of throwing not-prepared, Play + and Horizon report the provider's not-prepared, and + failures read{' '} + Failed to check active subscriptions [code]: ... instead + of Failed to get active subscriptions [code]: ...; match + codes, not messages. +

    +

    + Who is affected: apps that grant access during + billing grace, and Flutter apps that match the failure message or call + it before initConnection. +

    +

    + What to do: to keep grace access, read the renewal + info from getActiveSubscriptions instead of the boolean. +

    + {`import { getActiveSubscriptions } from 'react-native-iap'; + +// hasActiveSubscriptions() is false in billing grace; use renewal info +// when grace keeps access. +const subs = await getActiveSubscriptions(); +const inGrace = subs.some( + (sub) => (sub.renewalInfoIOS?.gracePeriodExpirationDate ?? 0) > Date.now(), +);`} +
    + {/* --------------------------------------------------------------- Migration train: 2.x -> 3.0 Add the next train as a sibling
    ABOVE this one (newest @@ -684,7 +1162,7 @@ function Migration() {

    PurchaseAndroid.dataAndroid is the only public, - schema-defined field for Google Play's raw signed purchase JSON. + schema-defined field for Google Play's raw signed purchase JSON.{' '} originalJsonAndroid is not a public Purchase field and is never the preferred output key.

    @@ -765,7 +1243,7 @@ function Migration() { Flutter 10 package-specific migrations

    - In addition to the generated OpenIAP schema surfaces below, + In addition to the generated OpenIAP schema surfaces below,{' '} flutter_inapp_purchase 10.0.0 removes these Flutter-only compatibility APIs:

    diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 51625b1e3..04e5e979e 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -102,6 +102,31 @@ const AMAZON_OFFER_FIX_RELEASES: readonly ReleaseMetadata[] = [ { name: '@hyodotdev/openiap', version: '0.1.1', tag: 'openiap-0.1.1' }, ]; +const COMMUNITY_PROVIDER_RELEASES: readonly ReleaseMetadata[] = [ + { name: 'openiap-apple', version: '4.0.0', tag: '4.0.0' }, + { name: 'openiap-google', version: '4.0.0', tag: 'google-4.0.0' }, + { + name: 'react-native-iap', + version: '17.0.0', + tag: 'react-native-iap-17.0.0', + }, + { name: 'expo-iap', version: '6.0.0', tag: 'expo-iap-6.0.0' }, + { + name: 'flutter_inapp_purchase', + version: '11.0.0', + tag: 'flutter-iap-11.0.0', + }, + { name: 'godot-iap', version: '4.0.0', tag: 'godot-iap-4.0.0' }, + { name: 'kmp-iap', version: '4.0.0', tag: 'kmp-iap-4.0.0' }, + { name: 'maui-iap', version: '3.0.0', tag: 'maui-iap-3.0.0' }, + { + name: '@hyodotdev/openiap-client-protocol', + version: '0.2.0', + tag: 'openiap-client-protocol-0.2.0', + }, + { name: '@hyodotdev/openiap', version: '0.2.0', tag: 'openiap-0.2.0' }, +]; + const MINIFIED_RELEASE_BUILD_RELEASES: readonly ReleaseMetadata[] = [ { name: 'openiap-google', version: '3.6.1', tag: 'google-3.6.1' }, { @@ -463,6 +488,251 @@ function Releases() { } const allNotes: Note[] = [ + { + id: 'community-store-providers-2026-10-02', + aliases: COMMUNITY_PROVIDER_RELEASES.map((release) => release.tag), + date: new Date('2026-10-02'), + element: ( +
    + {COMMUNITY_PROVIDER_RELEASES.map((release) => ( + +
    + ), + }, { id: 'amazon-subscription-offers-2026-09-30', aliases: AMAZON_OFFER_FIX_RELEASES.map((release) => release.tag), @@ -2566,7 +2836,7 @@ function Releases() { >
  • godot-iap 3.3.3 - replaces{' '} - supported_platforms, which is not part of the + supported_platforms, which is not part of the{' '} .gdextension format and never gated anything, with{' '} include_tags ( Framework SDK patches — Expo, React Native, Flutter, and KMP patch releases pick up the new native Apple/Google versions and ship the synchronized examples and - tests. React Native's latest published patch for this rollout is + tests. React Native's latest published patch for this rollout is{' '} 15.2.1. No breaking JS, Dart, or Kotlin API changes are required for this parity patch.
  • @@ -10489,7 +10759,7 @@ function Releases() { the Android side. makePurchaseError(...) accepts a{' '} debugMessage: parameter, and the StoreKit catch sites (product query, promoted product, promotional offer - failure) now forward error.localizedDescription + failure) now forward error.localizedDescription{' '} into it so iOS callers get the same structured diagnostic shape.
  • diff --git a/packages/docs/src/pages/home.tsx b/packages/docs/src/pages/home.tsx index f2c10c42a..f8740bc7c 100644 --- a/packages/docs/src/pages/home.tsx +++ b/packages/docs/src/pages/home.tsx @@ -627,8 +627,7 @@ function Home() {
    - 01 - Sponsors + 01 Sponsors
    {CURRENT_SPONSORS.map((sponsor) => { diff --git a/packages/docs/src/pages/introduction.tsx b/packages/docs/src/pages/introduction.tsx index eafc65f5d..1d7c0f4de 100644 --- a/packages/docs/src/pages/introduction.tsx +++ b/packages/docs/src/pages/introduction.tsx @@ -163,9 +163,8 @@ function Introduction() {

    Why OpenIAP

    - Stop translating purchases - One generated, type-safe contract keeps every store and framework - SDK aligned. + Stop translating purchases One generated, + type-safe contract keeps every store and framework SDK aligned.

    diff --git a/packages/docs/src/styles/store-providers.css b/packages/docs/src/styles/store-providers.css new file mode 100644 index 000000000..956bff711 --- /dev/null +++ b/packages/docs/src/styles/store-providers.css @@ -0,0 +1,419 @@ +.store-provider-diagram { + margin: 1.5rem 0 2rem; + padding: 1.5rem; + border: 1px solid var(--border-color, #374151); + border-radius: 1rem; + background: var(--card-bg, rgba(127, 127, 127, 0.04)); +} +.store-provider-diagram-heading { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} +.doc-page .store-provider-diagram h3 { + margin: 0; + font-size: 1.1rem; +} +.store-provider-platforms { + display: flex; + gap: 0.25rem; +} +.store-provider-platforms button { + padding: 0.5rem 0.9rem; + border: 1px solid var(--border-color, #374151); + border-radius: 0.5rem; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; +} +.store-provider-platforms button[aria-pressed='true'] { + background: var(--primary-color, #6366f1); + color: white; + border-color: transparent; +} +.store-provider-platforms button:focus-visible { + outline: 2px solid var(--primary-color, #6366f1); + outline-offset: 3px; +} +.doc-page ol.store-provider-pipeline { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1.3rem; + padding: 0; + margin: 1.5rem 0; + list-style: none; +} +.store-provider-pipeline > li { + position: relative; + display: flex; + margin: 0; + min-width: 0; +} +.store-provider-node { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1rem 0.75rem; + border: 1px solid var(--border-color, #374151); + border-radius: 0.75rem; + width: 100%; +} +.store-provider-node svg { + color: var(--primary-color, #6366f1); +} +.store-provider-node span { + font-size: 0.8rem; + line-height: 1.5; + opacity: 0.8; +} +.store-provider-arrow { + position: absolute; + left: calc(100% + 0.1rem); + top: 50%; + transform: translateY(-50%); +} +.store-provider-arrow-narrow { + display: none; +} +.doc-page .store-provider-installation { + margin: 0; + font-size: 0.9rem; +} +.store-provider-lifecycle { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color, #374151); +} +.doc-page .store-provider-lifecycle ol { + padding: 0; + list-style: none; + margin: 1rem 0; +} +.store-provider-lifecycle li { + display: flex; + gap: 0.9rem; + margin-bottom: 1rem; +} +.store-provider-lifecycle li > span { + display: grid; + place-items: center; + width: 1.8rem; + height: 1.8rem; + flex-shrink: 0; + border-radius: 50%; + background: var(--primary-color, #6366f1); + color: white; + font-size: 0.8rem; +} +.doc-page .store-provider-lifecycle li p { + margin: 0.25rem 0 0; +} +.doc-page .store-provider-lifecycle > p { + margin-bottom: 0; + font-size: 0.9rem; +} +@media (max-width: 900px) { + .doc-page ol.store-provider-pipeline { + grid-template-columns: 1fr; + gap: 1.5rem; + } + .store-provider-node { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + } + .store-provider-node span { + flex-basis: 100%; + } + .store-provider-arrow { + top: calc(100% + 0.1rem); + left: 50%; + transform: translateX(-50%); + } + .store-provider-arrow-wide { + display: none; + } + .store-provider-arrow-narrow { + display: block; + } +} +@media (max-width: 480px) { + .store-provider-diagram { + padding: 1rem; + } +} + +.provider-example { + margin: 1.5rem 0 2rem; +} +.provider-example-intro { + display: flex; + align-items: flex-start; + gap: 1.5rem; +} +.provider-example-intro > div { + flex: 1; +} +.provider-example-eyebrow { + color: var(--primary-color, #6366f1); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.09em; +} +.doc-page .provider-example h3 { + margin: 0.7rem 0 1rem; +} +.provider-example-repo { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color, #374151); + border-radius: 0.65rem; + font-size: 0.85rem; +} +.provider-example-identity { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; + margin: 1rem 0 1.5rem; + font-size: 0.85rem; +} +.provider-example-identity span { + opacity: 0.75; +} +.provider-example-showcase { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr); + gap: 1.5rem; + padding: 1.5rem; + background: var(--card-bg, rgba(127, 127, 127, 0.04)); + border: 1px solid var(--border-color, #374151); + border-radius: 1rem; +} +.provider-example-screen-picker { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.provider-example-screen-picker button { + display: grid; + grid-template-columns: 2rem 1fr; + text-align: left; + gap: 0.4rem 0.6rem; + font: inherit; + color: inherit; + background: transparent; + border: 1px solid var(--border-color, #374151); + border-radius: 0.8rem; + padding: 1rem; + cursor: pointer; +} +.provider-example-screen-picker button > span { + grid-row: span 2; + color: var(--primary-color, #6366f1); + font-size: 0.85rem; + font-weight: 700; +} +.provider-example-screen-picker strong { + font-size: 0.95rem; +} +.provider-example-screen-picker small { + font-size: 0.8rem; + line-height: 1.6; + opacity: 0.8; +} +.provider-example-screen-picker button[aria-pressed='true'] { + border-color: var(--primary-color, #6366f1); + box-shadow: inset 3px 0 var(--primary-color, #6366f1); +} +.provider-example-screen-picker button:focus-visible { + outline: 2px solid var(--primary-color, #6366f1); + outline-offset: 3px; +} +.provider-example-mobile { + display: none; +} +.provider-example-mobile details { + border: 1px solid var(--border-color, #374151); + border-radius: 0.85rem; + overflow: hidden; + background: var(--card-bg, rgba(127, 127, 127, 0.04)); +} +.provider-example-mobile details[open] { + border-color: var(--primary-color, #6366f1); +} +.provider-example-mobile summary { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + cursor: pointer; + list-style: none; + min-height: 3.5rem; +} +.provider-example-mobile summary::-webkit-details-marker { + display: none; +} +.provider-example-mobile summary:focus-visible { + outline: 2px solid var(--primary-color, #6366f1); + outline-offset: -4px; +} +.provider-example-step { + color: var(--primary-color, #6366f1); + font-size: 0.8rem; + font-weight: 700; +} +.provider-example-mobile strong { + flex: 1; + font-size: 0.9rem; +} +.provider-example-mobile summary svg { + flex-shrink: 0; + transition: transform 150ms ease; +} +.provider-example-mobile details[open] summary svg { + transform: rotate(180deg); +} +.provider-example-mobile figure { + padding: 0.25rem 1rem 1rem; +} +.provider-example figure { + margin: 0; + min-width: 0; +} +.provider-example figure img { + display: block; + width: 100%; + height: auto; + max-height: 620px; + object-fit: contain; + border-radius: 0.7rem; +} +.provider-example figcaption { + font-size: 0.8rem; + line-height: 1.6; + opacity: 0.8; + margin-top: 0.8rem; +} +.provider-example-evidence { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin: 1.5rem 0 1rem; +} +.provider-example-evidence > div { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1rem; + border: 1px solid var(--border-color, #374151); + border-radius: 0.8rem; +} +.provider-example-evidence svg, +.provider-example-files svg { + color: var(--primary-color, #6366f1); +} +.provider-example-evidence span { + font-size: 0.85rem; + line-height: 1.6; +} +.doc-page .provider-example-evidence-note { + font-size: 0.85rem; +} +.provider-example-files { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin: 1rem 0 2rem; +} +.provider-example-files a { + display: flex; + flex-direction: column; + gap: 0.65rem; + padding: 1rem; + border: 1px solid var(--border-color, #374151); + border-radius: 0.8rem; + color: inherit; + text-decoration: none; +} +.provider-example-files a:hover { + border-color: var(--primary-color, #6366f1); +} +.provider-example-files span { + font-size: 0.85rem; + line-height: 1.6; +} +.provider-example-files code { + font-size: 0.7rem; + overflow-wrap: anywhere; +} +.doc-page .provider-example-run li { + margin-bottom: 0.8rem; + line-height: 1.7; +} +.provider-example-brief { + margin-top: 1.5rem; + border: 1px solid var(--border-color, #374151); + border-radius: 0.8rem; + padding: 1rem; +} +.provider-example-brief summary { + font-weight: 600; + cursor: pointer; +} +.provider-example-static-screens { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; +} +@media (max-width: 900px) { + .provider-example-intro { + flex-direction: column; + gap: 0.5rem; + } + .provider-example-evidence { + grid-template-columns: 1fr; + } + .provider-example figure img { + max-height: 560px; + } +} +@media (max-width: 767px) { + .provider-example-desktop { + display: none; + } + .provider-example-mobile { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + .provider-example-files, + .provider-example-static-screens { + grid-template-columns: 1fr; + } +} +@media (min-width: 768px) and (max-width: 1024px) { + .provider-example-showcase { + gap: 1rem; + padding: 1rem; + } + .provider-example-screen-picker button { + padding: 0.8rem; + grid-template-columns: 1.4rem 1fr; + } + .provider-example-screen-picker small { + display: none; + } +} +@media (max-width: 480px) { + .provider-example-mobile figure img { + max-height: none; + } +} diff --git a/packages/google/CONVENTION.md b/packages/google/CONVENTION.md index b2d486819..3ba303322 100644 --- a/packages/google/CONVENTION.md +++ b/packages/google/CONVENTION.md @@ -119,3 +119,7 @@ Some implementation helpers exist only on specific Android flavors: ./gradlew :openiap:compileHorizonDebugKotlin ./gradlew :openiap:compileAmazonDebugKotlin ``` + +## Store Providers + +Android reaches the selected store only through `OpenIapProvider` manifest discovery; the contract, identity rules, and parity checklist live in [02-architecture.md](../../knowledge/internal/02-architecture.md#store-providers) and [04-platform-packages.md](../../knowledge/internal/04-platform-packages.md#provider-identity-and-selection). diff --git a/packages/google/compatibility/community-provider/E2E_RESULTS.md b/packages/google/compatibility/community-provider/E2E_RESULTS.md new file mode 100644 index 000000000..ae9243b20 --- /dev/null +++ b/packages/google/compatibility/community-provider/E2E_RESULTS.md @@ -0,0 +1,70 @@ +# Common provider device E2E — 2026-10-02 + +All 31 official matrix cells built. The 21 Apple, Play, and Amazon cells completed sandbox purchase, local IAPKit verification, and transaction finish. Onside passed its separate build-only check. Seven Horizon and two Vega purchase cells remain blocked. + +Device evidence covers `b6a531e1` through `63b54bc0`; later commits changed runtime code, so later report edits do not imply device execution at a newer commit. React Native's canonical ownership query and the strengthened community fixture were also exercised with the final source corrections. + +## Matrix + +`PASS` in the final column means purchase, verification, and finish all completed. `BLOCKED` and `build-only` do not claim a completed purchase. + +| Framework | Store | Build | Install / launch | Purchase / verify / finish | Local evidence | +| ------------ | ------- | ---------------------- | ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------ | +| native | apple | PASS | PASS | PASS | `native-ios-finished.png` | +| native | play | PASS | PASS | PASS | `native-play-result.png` | +| native | amazon | PASS | PASS | PASS | `native-amazon-fresh-finished.png` | +| native | horizon | PASS | PASS | BLOCKED | `native-horizon-full-checkout.xml` | +| react-native | apple | PASS | PASS | PASS | `react-native-ios-final-verified-finished.png` | +| react-native | play | PASS | PASS | PASS | `react-native-play-verified-finished.png` | +| react-native | amazon | PASS | PASS | PASS | `react-native-amazon-verified-finished.png` | +| react-native | horizon | PASS | PASS | BLOCKED | `react-native-horizon-checkout.xml` | +| react-native | vega | PASS (debug + release) | PASS | BLOCKED | `logs/react-native-vega-final-running.log, logs/vega-input-help.log, logs/vega-capture-help.log` | +| expo | apple | PASS | PASS | PASS | `expo-ios-verified-finished.png` | +| expo | play | PASS | PASS | PASS | `expo-play-verified-finished.png` | +| expo | amazon | PASS | PASS | PASS | `expo-amazon-verified-finished.png` | +| expo | horizon | PASS | PASS | BLOCKED | `expo-horizon-checkout-ready.xml` | +| expo | vega | PASS (debug + release) | PASS | BLOCKED | `logs/expo-vega-final-running.log, logs/vega-input-help.log, logs/vega-capture-help.log` | +| expo | onside | PASS | n/a | build-only | `logs/expo-onside-build.log` | +| flutter | apple | PASS | PASS | PASS | `flutter-ios-fresh-verified-finished.png` | +| flutter | play | PASS | PASS | PASS | `flutter-play-verification.png` | +| flutter | amazon | PASS | PASS | PASS | `flutter-amazon-verification.png` | +| flutter | horizon | PASS | PASS | BLOCKED | `flutter-horizon-checkout-loaded.xml` | +| kmp | apple | PASS | PASS | PASS | `kmp-ios-fresh-json-verified-finished.png` | +| kmp | play | PASS | PASS | PASS | `kmp-play-verified-finished.png` | +| kmp | amazon | PASS | PASS | PASS | `kmp-amazon-verified-finished.png` | +| kmp | horizon | PASS | PASS | BLOCKED | `kmp-horizon-full-checkout.xml` | +| maui | apple | PASS | PASS | PASS | `maui-ios-fresh-finished.png` | +| maui | play | PASS | PASS | PASS | `maui-play-verified-finished.png` | +| maui | amazon | PASS | PASS | PASS | `maui-amazon-verified-finished.png` | +| maui | horizon | PASS | PASS | BLOCKED | `maui-horizon-checkout.xml` | +| godot | apple | PASS | PASS | PASS | `godot-ios-event-finished.png` | +| godot | play | PASS | PASS | PASS | `godot-play-final-recheck.png` | +| godot | amazon | PASS | PASS | PASS | `godot-amazon-final-game-recheck.png` | +| godot | horizon | PASS | PASS | BLOCKED | `godot-horizon-final-app.xml` | + +Evidence is retained in the private local directory `/tmp/openiap-common-provider-e2e-20261002/`. Raw receipts, credentials, account details, and private backend rows are not committed. + +## Independent providers + +- The separately published `community.fixture:provider:1.0.0` initially passed the 17-behavior Android profile on suite 4.0.0 and Client Protocol 0.2.0. The final Apple and Android fixtures pass the expanded 18-behavior profile, including exactly one canonical error event before a failed purchase request returns an empty result or throws. Negative cases reject missing capabilities, invalid platform/token behavior, and missing, duplicate, or contradictory failure events. These final checks ran 15 Swift tests and 26 Android tests; they are local conformance checks, not another hardware-matrix run. +- Expo, KMP, and MAUI ran the strengthened fixture on the Pixel. All fetched products, purchased, and finished with `store = unknown` and `storeId = community-fixture`. Product loading called its separately published vendor SDK, the Kotlin Multiplatform JVM date runtime, and the core Compose context. They used **None (Skip)** verification because the fixture has no receipt-validation service. +- A separately packed MAUI NuGet consumer resolved the same provider, vendor runtime, and core dependencies with one core artifact. Its Gradle wrapper and resolver came from the package. +- React Native's final ownership-query correction fetched an existing Play purchase through the required common API on hardware. +- After the public-method corrections, fresh Expo and KMP fixture builds again loaded the vendor JVM and core Compose dependencies, purchased with `None (Skip)`, and finished. Expo also returned `true` from its public redemption override with the optional handler unset; its purchase detail showed `store = unknown` and `storeId = community-fixture`. This recheck covers those corrected paths, not a rerun of the 31 official cells. +- The Apple fixture builds independently against the public OpenIAP Swift product. Its conformance and discovery checks cover the matching Apple provider contract; this does not claim a third-party Apple store purchase on hardware. + +## Backend and recovery evidence + +- Every passing official purchase flow used the locally compiled IAPKit server and the dev-only Convex deployment. App evidence records a valid verification result and completion after awaiting finish. +- Private before/after snapshots and structured server logs retain canonical identity hashes and correlation identifiers. Concurrent device checks and retries can add more than one row between snapshots; aggregate row deltas are not assigned to individual frameworks. +- Local network permission or URL failures left the transaction unfinished. The examples recovered those transactions through the normal verify-then-finish path before fresh consumable checks. +- The latest Godot Play run also exercised **Restore Purchases** through the selected native provider, verified the owned purchase, and finished it. Fresh Play and Amazon consumables were then verified and finished. + +## External limits + +- Amazon used App Tester. Live App Testing remains blocked because the current candidate was not distributed through that channel. App Tester does not prove the live Appstore foreground purchase Activity. +- Six Horizon apps reached a checkout showing KRW14 and **Confirm**, without a sandbox/no-charge marker. Confirmation was withheld. Godot launched and fetched products but its Horizon canvas could not be driven with the available device input. No Horizon purchase, verification, or finish is claimed. +- Both Vega release apps installed, launched, and remained running. The device exposes neither the required purchase input nor a usable screen-capture command. Their purchase, verification, and finish remain blocked. +- Expo Onside prebuild, pods, and the unsigned device build passed. The app embeds `OnsideKit.framework` and the `dev.hyo.martie.onside-auth` callback scheme. No Onside runtime purchase is claimed. + +No merge, package release, production data write, or production deployment was performed. Temporary device settings were restored and task-owned local processes stopped. The original Quest app was restored and its APK hash verified. diff --git a/packages/google/compatibility/community-provider/README.md b/packages/google/compatibility/community-provider/README.md new file mode 100644 index 000000000..cca5b50c5 --- /dev/null +++ b/packages/google/compatibility/community-provider/README.md @@ -0,0 +1,88 @@ +# Independent Android provider fixture + +`community.fixture:provider:1.0.0` is an in-memory store in the +`community.fixture` namespace. It compiles against Maven artifacts only: +`openiap-core` and its separately published `vendor-sdk` for implementation, +and `openiap-conformance` for tests. +It has no official billing SDK, composite build, or official provider source +dependency. It is deliberately absent from the store registry. + +Run from the monorepo root with Java 17 and an Android SDK: + +```sh +bash packages/google/scripts/verify-community-provider.sh +``` + +The script publishes into a temporary local Maven repository and checks: + +- The provider passes the Android provider profile, including all three + declared capabilities. Reports include suite and Client Protocol versions. +- A provider that declares but omits a billing-issue event fails conformance. +- The minified host selects the provider with the two Gradle properties, keeps + its manifest factory, and contains no official billing SDK. +- Two different provider manifests fail merging. +- Product loading calls the separate vendor SDK, its Kotlin Multiplatform JVM + runtime, and the core's Compose context. Missing runtime dependencies fail + before the store can serve products. +- The Gradle plugin resolves core plus provider for plain and flavored apps; + a provider flavor without coordinates fails. + +Reports remain in `provider/build/reports/openiap/`; the negative report must +have `conformant: false`. The host APK is +`host/build/outputs/apk/release/host-release.apk`. + +## Use it in the SDK examples + +Keep a local Maven repository for device work. Read the current Google version +from `openiap-versions.json` and the suite version from +`packages/conformance/src/spec/suite-version.mjs`; use them for the two fixture +version properties below. + +```sh +packages/google/gradlew -p packages/google \ + :openiap-core:publishToMavenLocal :openiap-conformance:publishToMavenLocal \ + -Dmaven.repo.local=/tmp/openiap-provider-maven + +packages/google/gradlew -p packages/google/compatibility/community-provider \ + :provider:negativeConformance :vendor-sdk:publishVendorPublicationToMavenLocal :provider:publishFixturePublicationToMavenLocal \ + -Dmaven.repo.local=/tmp/openiap-provider-maven \ + -PopenIapRepository=/tmp/openiap-provider-maven \ + -PopenIapVersion= -PconformanceVersion= +``` + +Add `/tmp/openiap-provider-maven` as a Maven repository in the consuming app, +then build with both properties: + +```properties +openiapStore=community-fixture +openiapProvider=community.fixture:provider:1.0.0 +``` + +Expo uses its usual Android debug task and Metro. KMP's example uses +`:composeApp:assembleProviderDebug`; its settings already include `mavenLocal`, +so pass `-Dmaven.repo.local=/tmp/openiap-provider-maven`. Give each test app a +distinct application id suffix before installing it. Choose **None (Skip)** +verification, request a fixture product, and finish it. This fixture has no +receipt-validation service. Both examples display `community-fixture` on the +purchase result or detail screen. + +To verify consumption of KMP's Maven metadata as well as its local example, +publish only its root and neutral Android publications locally: + +```sh +libraries/kmp-iap/gradlew -p libraries/kmp-iap \ + :library:publishKotlinMultiplatformPublicationToMavenLocal \ + :library:publishAndroidProviderReleasePublicationToMavenLocal \ + -Dmaven.repo.local=/tmp/openiap-provider-maven + +packages/google/gradlew -p packages/google/compatibility/store-plugin \ + :app:printDebugStores \ + -PfixtureCommunityRepository=/tmp/openiap-provider-maven \ + -PfixtureKmpVersion= -PfixtureStrategy=provider \ + -PopeniapStore=community-fixture \ + -PopeniapProvider=community.fixture:provider:1.0.0 +``` + +The graph must contain `kmp-iap-android-provider`, `openiap-core`, and `provider` +without an official store artifact. See [VERIFICATION.md](VERIFICATION.md) for +the recorded checks, limitations, and release follow-ups. diff --git a/packages/google/compatibility/community-provider/VERIFICATION.md b/packages/google/compatibility/community-provider/VERIFICATION.md new file mode 100644 index 000000000..3d0d3cd41 --- /dev/null +++ b/packages/google/compatibility/community-provider/VERIFICATION.md @@ -0,0 +1,89 @@ +# Common store provider verification + +Apple and Android expose the same provider model: independently maintained +implementations, explicit discovery and selection, required capabilities, +canonical errors, and stable purchase identity. All six SDKs dispatch through +the selected native provider. No additional real store implementation ships +in this change. + +New stores outside the fixed `IapStore` enum use `store = unknown` and their own +required `storeId`. Registration is optional. The registry +produces generated constants, aliases, CLI checks, and the public listing. +Provider selection does not add IAPKit server validation for a new store. + +## Verification coverage + +| Area | Executed coverage | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Android provider | Independent local Maven publication, required behavioral profile, negative capability/platform/token cases, discovery, manifest conflicts, and optimized host | +| Apple provider | Independent Swift package, matching behavioral profile, explicit discovery failures, and optimized factory retention | +| SDK dispatch | React Native, Expo, Flutter, Godot, KMP, and MAUI selection and identity handling; public storefront/redemption overrides, community KMP billing operations/events, ownership/restore, and completion | +| Provider dependencies | Neutral Gradle consumer graphs and MAUI Maven runtime closure, including a separate vendor SDK and credits for existing app dependencies | +| Contracts | Client Protocol generation and sync, frozen store enum, required store ids, legacy official identity decoding, and JavaScript/native conformance | +| Release wiring | Registry validation, SBOM/provenance inputs, stable-release preflight, native core publication, framework packaging, and affected CI paths | +| Documentation | Common provider authoring/selection guide, API/type consistency, affected release card, and production-site build | +| Devices | 31 official build cells, 21 complete sandbox purchase/verify/finish cells, three independent-provider runtime flows, and explicit external limits | + +See [E2E_RESULTS.md](E2E_RESULTS.md) for every device row and limitation. +Use [README.md](README.md) and the Apple compatibility fixture to reproduce +independent publication and conformance. Artifacts and raw device/backend +evidence remain in ignored build outputs or the private local run directory. + +Both independent fixtures pass all 18 common provider behaviors, including +exactly one canonical error event before a failed purchase request returns an +empty result or throws. +Negative cases reject missing, duplicate, and contradictory events. Swift +ownership reads must retain the token from the original purchase callback; +matching later reads alone cannot pass. The final fixture checks ran 15 Swift +tests and 26 Android tests, with no failures. + +MAUI uses Gradle to resolve and verify the provider's dependency graph, +including Maven POMs, module-metadata runtime variants, BOMs, exclusions, +and version conflicts. The bundled core retains its runtime dependencies. +Restored NuGet artifact tags and the app's explicit Java artifact metadata +identify dependencies already supplied by the app. A provider requiring a +newer version fails instead of replacing those binaries or linking duplicates. +Only restored NuGet modules use their already selected runtime closures. +Resolved metadata redirects receive exact logical-artifact credits for the +.NET Maven verifier; no missing dependency is ignored. + +## Boundaries + +- Android core owns shared source compilation and publication; the source stays + under `openiap/src/main` to preserve the generated-type sync owner. +- KMP's neutral variant has Play Billing on its compile-only classpath because + the existing source set also contains Play code. Its community runtime graph + excludes that SDK. +- Native provider profiles exercise the public client contracts and declared + capabilities. They do not certify server-only commerce behavior. +- Godot invalid export settings emit an error and an unresolvable coordinate, + because its export hook cannot cancel an export directly. +- Apple providers must be linked and retained in optimized app builds; an + Info.plist factory name does not link a binary by itself. + +## Release targets + +Client Protocol **0.2.0** is set in its publishing manifest and generated +mirrors. The release card includes the following future stable publications. +Native and framework versions remain managed by their existing release +workflows; local Maven/SPM checks do not claim public publication. + +| Publication | Release card target | +| ----------------------------- | ------------------- | +| Client Protocol / CLI | 0.2.0 / 0.2.0 | +| Google / core / Gradle plugin | 4.0.0 | +| Android conformance | Suite 4.0.0 | +| Apple | 4.0.0 | +| React Native | 17.0.0 | +| Expo | 6.0.0 | +| Flutter | 11.0.0 | +| Godot / KMP | 4.0.0 / 4.0.0 | +| MAUI | 3.0.0 | + +Existing official aliases and deprecated configuration paths remain supported +through this provider release. Their removal moves to the following major +release. Commerce Protocol and IAPKit have no source/publication change in +this task. + +PR review and CI must be evaluated at the exact current head. The maintainer +retains final validation, merge, release, and deployment authority. diff --git a/packages/google/compatibility/community-provider/build.gradle.kts b/packages/google/compatibility/community-provider/build.gradle.kts new file mode 100644 index 000000000..6f159b7ff --- /dev/null +++ b/packages/google/compatibility/community-provider/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.library") version "8.13.2" apply false + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.2.0" apply false +} diff --git a/packages/google/compatibility/community-provider/gradle.properties b/packages/google/compatibility/community-provider/gradle.properties new file mode 100644 index 000000000..d08978679 --- /dev/null +++ b/packages/google/compatibility/community-provider/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8 diff --git a/packages/google/compatibility/community-provider/host/build.gradle.kts b/packages/google/compatibility/community-provider/host/build.gradle.kts new file mode 100644 index 000000000..736188b55 --- /dev/null +++ b/packages/google/compatibility/community-provider/host/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { id("com.android.application"); id("org.jetbrains.kotlin.android") } +apply(from = file("../../../gradle/openiap-store.gradle")) +android { + namespace = "community.fixture.host" + compileSdk = 36 + defaultConfig { applicationId = "community.fixture.host"; minSdk = 23; targetSdk = 36; versionCode = 1; versionName = "1" } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } + buildTypes { getByName("release") { isMinifyEnabled = true; signingConfig = signingConfigs.getByName("debug"); proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } +} +kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } +val addDependencies = extra["openIapAddStoreDependencies"] as groovy.lang.Closure<*> +addDependencies.call("implementation", providers.gradleProperty("openIapVersion").get()) +dependencies { if (providers.gradleProperty("duplicateProvider").isPresent) implementation(project(":second-provider")) } diff --git a/packages/google/compatibility/community-provider/host/src/main/AndroidManifest.xml b/packages/google/compatibility/community-provider/host/src/main/AndroidManifest.xml new file mode 100644 index 000000000..311b6a5f1 --- /dev/null +++ b/packages/google/compatibility/community-provider/host/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/community-provider/host/src/main/java/community/fixture/host/MainActivity.kt b/packages/google/compatibility/community-provider/host/src/main/java/community/fixture/host/MainActivity.kt new file mode 100644 index 000000000..8036d1980 --- /dev/null +++ b/packages/google/compatibility/community-provider/host/src/main/java/community/fixture/host/MainActivity.kt @@ -0,0 +1,26 @@ +package community.fixture.host + +import android.app.Activity +import android.os.Bundle +import android.widget.TextView +import dev.hyo.openiap.* +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import kotlinx.coroutines.* + +class MainActivity : Activity() { + override fun onCreate(state: Bundle?) { + super.onCreate(state) + val output = TextView(this) + setContentView(output) + val provider = OpenIapProvider.create(this) + provider.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { purchase -> + val message = "store=${purchase.store.rawValue} storeId=${purchase.storeId} productId=${purchase.productId}" + output.text = message + android.util.Log.i("ProviderFixture", message) + }) + CoroutineScope(Dispatchers.Main).launch { + provider.initConnection(null) + provider.requestPurchase(RequestPurchaseProps.fromJson(mapOf("type" to "in-app", "requestPurchase" to mapOf("google" to mapOf("skus" to listOf("conformance.product")))))) + } + } +} diff --git a/packages/google/compatibility/community-provider/provider/build.gradle.kts b/packages/google/compatibility/community-provider/provider/build.gradle.kts new file mode 100644 index 000000000..79d510302 --- /dev/null +++ b/packages/google/compatibility/community-provider/provider/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { id("com.android.library"); id("org.jetbrains.kotlin.android"); id("maven-publish") } +val coreVersion = providers.gradleProperty("openIapVersion").get() +android { + namespace = "community.fixture" + compileSdk = 36 + defaultConfig { minSdk = 23; buildConfigField("String", "CORE_VERSION", "\"$coreVersion\"") } + buildFeatures { buildConfig = true } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } + testOptions.unitTests.isIncludeAndroidResources = true + publishing { singleVariant("release") } +} +kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } +dependencies { + implementation(project(":vendor-sdk")) + api("io.github.hyochan.openiap:openiap-core:$coreVersion") + testImplementation("io.github.hyochan.openiap:openiap-conformance:${providers.gradleProperty("conformanceVersion").get()}") + testImplementation("org.robolectric:robolectric:4.16.1") + testImplementation("androidx.test:core:1.7.0") +} +tasks.withType().configureEach { + include("**/FixtureConformanceTest.class") + systemProperty("openiap.conformanceReport", layout.buildDirectory.file("reports/openiap/{storeId}.json").get().asFile.path) +} +afterEvaluate { + publishing { + publications { create("fixture") { + from(components["release"]) + groupId = "community.fixture"; artifactId = "provider"; version = "1.0.0" + } } + } +} + +afterEvaluate { + val positive = tasks.named("testDebugUnitTest") + tasks.register("negativeConformance") { + dependsOn(positive) + testClassesDirs = positive.get().testClassesDirs + classpath = positive.get().classpath + setIncludes(setOf("**/MissingCapabilityTest.class")) + ignoreFailures = true + val report = layout.buildDirectory.file("reports/openiap/missing-capability.json").get().asFile + systemProperty("openiap.conformanceReport", report.path) + doLast { + check(report.readText().contains("\"conformant\": false")) + val results = reports.junitXml.outputLocation.get().asFile.resolve("TEST-community.fixture.MissingCapabilityTest.xml") + check(results.readText().contains("failures=\"1\"")) { "Expected exactly one declared-capability failure" } + } + } +} diff --git a/packages/google/compatibility/community-provider/provider/src/main/AndroidManifest.xml b/packages/google/compatibility/community-provider/provider/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f6a9b7869 --- /dev/null +++ b/packages/google/compatibility/community-provider/provider/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/google/compatibility/community-provider/provider/src/main/java/community/fixture/FixtureFactory.kt b/packages/google/compatibility/community-provider/provider/src/main/java/community/fixture/FixtureFactory.kt new file mode 100644 index 000000000..fcd6df806 --- /dev/null +++ b/packages/google/compatibility/community-provider/provider/src/main/java/community/fixture/FixtureFactory.kt @@ -0,0 +1,116 @@ +package community.fixture + +import android.app.Activity +import android.content.Context +import community.fixture.vendor.FixtureVendorSdk +import dev.hyo.openiap.* +import dev.hyo.openiap.listener.* + +class FixtureFactory : OpenIapProviderFactory { + override val storeId = STORE_ID + override val coreVersion = BuildConfig.CORE_VERSION + override val clientProtocolVersion = "0.2.0" + override val capabilities = setOf("pendingPurchases", "subscriptionBillingIssue", "offerCodeRedemption") + override fun create(context: Context): OpenIapProtocol = FixtureProvider() + companion object { const val STORE_ID = "community-fixture" } +} + +/** In-memory compatibility fixture, never a real store or receipt validator. */ +class FixtureProvider : OpenIapProtocol { + private val updates = mutableSetOf() + private val issues = mutableSetOf() + private val errors = mutableSetOf() + private val owned = linkedMapOf() + private var connected = false + private var nextTransaction = 0 + private fun unsupported(): Nothing = throw OpenIapError.FeatureNotSupported("Fixture does not implement this operation") + override val initConnection: MutationInitConnectionHandler = { connected = true; true } + override val endConnection: MutationEndConnectionHandler = { connected = false; true } + override val fetchProducts: QueryFetchProductsHandler = { params -> + check(connected) + val contextName = IapContext.LocalOpenIapStore.javaClass.simpleName + FetchProductsResultProducts(params.skus.map { id -> ProductAndroid( + currency = "USD", description = "${FixtureVendorSdk.productDescription()} ($contextName)", displayPrice = "$1.00", id = id, + nameAndroid = id, title = id, + ) }) + } + override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { owned.values.toList() } + override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { ids -> + owned.values.filter { ids == null || it.productId in ids }.map(::toActiveSubscription) + } + override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { ids -> getActiveSubscriptions(ids).any { it.isActive } } + override val requestPurchase: MutationRequestPurchaseHandler = { params -> + if (!connected) failRequest(OpenIapError.NotPrepared) + val skus = when (val request = params.request) { + is RequestPurchaseProps.Request.Purchase -> request.value.google?.skus + is RequestPurchaseProps.Request.Subscription -> request.value.google?.skus + } + val sku = skus?.firstOrNull()?.takeIf { it.isNotBlank() } ?: failRequest(OpenIapError.EmptySkuList) + val transaction = ++nextTransaction + val purchase = purchase(sku).copy(id = "fixture:$sku:$transaction", purchaseToken = "fixture-token:$sku:$transaction") + owned[sku] = purchase + updates.toList().forEach { it.onPurchaseUpdated(purchase) } + RequestPurchaseResultPurchase(purchase) + } + override val finishTransaction: MutationFinishTransactionHandler = { purchase, consumable -> + if (consumable == true) owned.remove(purchase.productId) + } + override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { true } + override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { token -> owned.entries.removeAll { it.value.purchaseToken == token }; true } + override val restorePurchases: MutationRestorePurchasesHandler = {} + override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = {} + override val verifyPurchase: MutationVerifyPurchaseHandler = { unsupported() } + override val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler = { unsupported() } + override val queryHandlers get() = QueryHandlers(fetchProducts = fetchProducts, getAvailablePurchases = getAvailablePurchases, + getActiveSubscriptions = getActiveSubscriptions, hasActiveSubscriptions = hasActiveSubscriptions) + override val mutationHandlers get() = MutationHandlers(initConnection = initConnection, endConnection = endConnection, + requestPurchase = requestPurchase, finishTransaction = finishTransaction, restorePurchases = restorePurchases) + override val subscriptionHandlers = SubscriptionHandlers() + override fun setActivity(activity: Activity?) {} + override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { updates.add(listener) } + override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { updates.remove(listener) } + override fun addSubscriptionBillingIssueListener(listener: OpenIapSubscriptionBillingIssueListener) { issues.add(listener) } + override fun removeSubscriptionBillingIssueListener(listener: OpenIapSubscriptionBillingIssueListener) { issues.remove(listener) } + override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { errors.add(listener) } + override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { errors.remove(listener) } + override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) {} + override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) {} + override fun addDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) {} + override fun removeDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) {} + override fun addConnectionStateListener(listener: OpenIapConnectionStateListener) {} + override fun removeConnectionStateListener(listener: OpenIapConnectionStateListener) {} + override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid = unsupported() + override suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid, developerBillingType: DeveloperBillingTypeAndroid?): BillingProgramReportingDetailsAndroid = unsupported() + override suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean = unsupported() + override suspend fun getBillingChoiceInfo(params: GetBillingChoiceInfoParamsAndroid): BillingChoiceInfoAndroid = unsupported() + override suspend fun showBillingProgramInformationDialog(activity: Activity, params: BillingProgramInformationDialogParamsAndroid): BillingResultAndroid = unsupported() + override suspend fun showInAppMessages(activity: Activity, params: InAppMessageParamsAndroid?): InAppMessageResultAndroid = unsupported() + override suspend fun openRedeemOfferCode(activity: Activity) = true + override suspend fun getStorefront() = "US" + + private fun failRequest(error: OpenIapError): Nothing { + errors.toList().forEach { it.onPurchaseError(error) } + throw error + } + + fun emitPending() { updates.toList().forEach { it.onPurchaseUpdated(purchase("conformance.product", PurchaseState.Pending)) } } + fun emitBillingIssue() { issues.toList().forEach { it.onSubscriptionBillingIssue(purchase("conformance.product").copy(isSuspendedAndroid = true)) } } + fun emitRedemption() { updates.toList().forEach { it.onPurchaseUpdated(purchase("conformance.product")) } } + + fun purchase(sku: String, state: PurchaseState = PurchaseState.Purchased) = PurchaseAndroid( + id = "fixture:$sku", productId = sku, purchaseToken = "fixture-token:$sku", quantity = 1, + isAutoRenewing = false, purchaseState = state, store = IapStore.Unknown, storeId = FixtureFactory.STORE_ID, + transactionDate = 1_700_000_000_000.0, + ) + fun toActiveSubscription(purchase: PurchaseAndroid) = ActiveSubscription( + isActive = purchase.purchaseState == PurchaseState.Purchased && purchase.isSuspendedAndroid != true, + productId = purchase.productId, currentPlanId = purchase.productId, purchaseToken = purchase.purchaseToken, + purchaseTokenAndroid = purchase.purchaseToken, transactionDate = purchase.transactionDate, + transactionId = purchase.id, + ) + fun mapError(code: String): OpenIapError = when(code) { + "cancelled" -> OpenIapError.UserCancelled() + "missing" -> OpenIapError.ItemUnavailable() + else -> OpenIapError.UnknownError() + } +} diff --git a/packages/google/compatibility/community-provider/provider/src/test/java/community/fixture/FixtureConformanceTest.kt b/packages/google/compatibility/community-provider/provider/src/test/java/community/fixture/FixtureConformanceTest.kt new file mode 100644 index 000000000..04903b565 --- /dev/null +++ b/packages/google/compatibility/community-provider/provider/src/test/java/community/fixture/FixtureConformanceTest.kt @@ -0,0 +1,188 @@ +package community.fixture + +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import dev.hyo.openiap.* +import dev.hyo.openiap.conformance.* +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +open class FixtureConformanceTest : ProviderConformanceSuite() { + override val factory: OpenIapProviderFactory = FixtureFactory() + protected val fixture = FixtureProvider() + override val provider: OpenIapProtocol = fixture + override val redemptionActivity: Activity = Robolectric.buildActivity(Activity::class.java).setup().get() + override val adapter = object : StoreConformanceAdapter { + override val store = IapStore.Unknown + override val storeId get() = factory.storeId + override val capabilities = StoreCapability.entries.toSet() + override fun toActiveSubscription(purchase: PurchaseAndroid) = fixture.toActiveSubscription(purchase) + override val normativeErrorCases = listOf( + StoreErrorCase("cancelled", ErrorCode.UserCancelled, fixture.mapError("cancelled")), + StoreErrorCase("missing", ErrorCode.ItemUnavailable, fixture.mapError("missing")), + ) + override val unrecognizedError = fixture.mapError("unrecognized") + } + override suspend fun triggerCapability(capability: StoreCapability) { + when (capability) { + StoreCapability.PendingPurchases -> fixture.emitPending() + StoreCapability.SubscriptionBillingIssue -> fixture.emitBillingIssue() + StoreCapability.OfferCodeRedemption -> fixture.emitRedemption() + } + } + @Test fun `merged library metadata discovers a class in an independent namespace`() { + val context: Context = ApplicationProvider.getApplicationContext() + assertEquals(factory.storeId, OpenIapProvider.factory(context).storeId) + assertTrue(OpenIapProvider.create(context) is FixtureProvider) + } + + @Test fun `suite rejects an incompatible Client Protocol build`() { + val actualFactory = factory + val incompatible = object : FixtureConformanceTest() { + override val factory = object : OpenIapProviderFactory { + override val storeId = actualFactory.storeId + override val coreVersion = actualFactory.coreVersion + override val clientProtocolVersion = "0.1.1" + override val capabilities = actualFactory.capabilities + override fun create(context: Context) = FixtureProvider() + } + } + assertThrows(OpenIapError.ProviderConfiguration::class.java) { incompatible.connectProvider() } + } + + @Test fun `suite rejects a provider that cannot restore`() { + val actualProvider = provider + val unsupported = object : FixtureConformanceTest() { + override val provider = object : OpenIapProtocol by actualProvider { + override val restorePurchases: MutationRestorePurchasesHandler = { throw OpenIapError.FeatureNotSupported() } + } + } + assertThrows(OpenIapError.FeatureNotSupported::class.java) { unsupported.`purchase restores and finishes with a stable identity`() } + } + @Test fun `suite rejects missing duplicate or contradictory failure events`() { + for (mode in listOf("missing", "duplicate", "purchase", "returned", "generic", "generic-missing", "generic-duplicate")) { + val invalid = object : FixtureConformanceTest() { + override val timeoutMillis = 50L + override val provider = object : OpenIapProtocol by fixture { + private var errors: dev.hyo.openiap.listener.OpenIapPurchaseErrorListener? = null + private var updates: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener? = null + override fun addPurchaseErrorListener(listener: dev.hyo.openiap.listener.OpenIapPurchaseErrorListener) { errors = listener } + override fun removePurchaseErrorListener(listener: dev.hyo.openiap.listener.OpenIapPurchaseErrorListener) { errors = null } + override fun addPurchaseUpdateListener(listener: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener) { updates = listener } + override fun removePurchaseUpdateListener(listener: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener) { updates = null } + override val requestPurchase: MutationRequestPurchaseHandler = { + val error = OpenIapError.EmptySkuList + if (mode != "missing" && mode != "generic-missing") errors?.onPurchaseError(error) + if (mode == "duplicate" || mode == "generic-duplicate") errors?.onPurchaseError(error) + if (mode == "purchase") updates?.onPurchaseUpdated(fixture.purchase("conformance.product")) + if (mode.startsWith("generic")) throw IllegalStateException("Vendor failure") + if (mode == "returned") RequestPurchaseResultPurchases(emptyList()) else throw error + } + } + } + if (mode == "returned" || mode == "generic") invalid.`invalid purchase emits one error before completion`() + else assertThrows(AssertionError::class.java) { invalid.`invalid purchase emits one error before completion`() } + } + } + @Test fun `conformance only buys an owned product once`() { + val actualProvider = provider + var requests = 0 + val persistent = object : FixtureConformanceTest() { + override val provider = object : OpenIapProtocol by actualProvider { + override val requestPurchase: MutationRequestPurchaseHandler = { props -> + if (++requests > 1) throw OpenIapError.ItemAlreadyOwned() + actualProvider.requestPurchase(props) + } + } + } + persistent.`purchase restores and finishes with a stable identity`() + assertEquals(1, requests) + } + @Test fun `suite rejects purchase updates from the wrong platform`() { + val actualProvider = provider + val invalid = object : FixtureConformanceTest() { + override val provider = object : OpenIapProtocol by actualProvider { + private var updateListener: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener? = null + override fun addPurchaseUpdateListener(listener: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener) { updateListener = listener } + override fun removePurchaseUpdateListener(listener: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener) { updateListener = null } + override val requestPurchase: MutationRequestPurchaseHandler = { + val purchase = PurchaseIOS.fromJson(fixture.purchase("conformance.product").toJson()) + updateListener?.onPurchaseUpdated(purchase) + RequestPurchaseResultPurchase(purchase) + } + } + } + assertThrows(AssertionError::class.java) { invalid.`purchase restores and finishes with a stable identity`() } + } + @Test fun `suite rejects altered restored purchase identities and tokens`() { + val actualProvider = provider + for (field in listOf("storeId", "purchaseToken")) { + val invalid = object : FixtureConformanceTest() { + override val provider = object : OpenIapProtocol by actualProvider { + override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { options -> + actualProvider.getAvailablePurchases(options).map { purchase -> + PurchaseAndroid.fromJson(purchase.toJson() + (field to "altered-value")) + } + } + } + } + assertThrows(AssertionError::class.java) { invalid.`purchase restores and finishes with a stable identity`() } + } + } + @Test fun `suite rejects redemption updates from the wrong platform`() { + val invalid = object : FixtureConformanceTest() { + private var updateListener: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener? = null + override val provider = object : OpenIapProtocol by fixture { + override fun addPurchaseUpdateListener(listener: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener) { updateListener = listener } + override fun removePurchaseUpdateListener(listener: dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener) { updateListener = null } + } + override suspend fun triggerCapability(capability: StoreCapability) { + updateListener?.onPurchaseUpdated(PurchaseIOS.fromJson(fixture.purchase("conformance.product").toJson())) + } + } + assertThrows(AssertionError::class.java) { invalid.`declared redemption capability reaches the purchase listener`() } + } + @Test fun `suite invokes redemption when the capability is missing`() { + val base = FixtureConformanceTest() + fun withoutRedemption(redemption: OpenIapProtocol) = object : FixtureConformanceTest() { + override val factory = object : OpenIapProviderFactory by base.factory { + override val capabilities = base.factory.capabilities - "offerCodeRedemption" + } + override val adapter = object : StoreConformanceAdapter by base.adapter { + override val capabilities = base.adapter.capabilities - StoreCapability.OfferCodeRedemption + } + override val provider = redemption + } + val throwing = withoutRedemption(object : OpenIapProtocol by base.provider { + override suspend fun openRedeemOfferCode(activity: Activity): Boolean = + throw OpenIapError.FeatureNotSupported("Fixture redemption is not implemented") + }) + assertThrows(OpenIapError.FeatureNotSupported::class.java) { + throwing.`unsupported offer code redemption returns its documented no-op`() + } + val wrongValue = withoutRedemption(object : OpenIapProtocol by base.provider { + override suspend fun openRedeemOfferCode(activity: Activity): Boolean = true + }) + assertThrows(AssertionError::class.java) { + wrongValue.`unsupported offer code redemption returns its documented no-op`() + } + } + @Test fun `repurchasing a consumed item creates a new transaction`() = runBlocking { + val request = RequestPurchaseProps.fromJson(mapOf( + "requestPurchase" to mapOf("google" to mapOf("skus" to listOf("conformance.product"))), "type" to "in-app", + )) + val first = (provider.requestPurchase(request) as RequestPurchaseResultPurchase).value!! + provider.finishTransaction(first, true) + val second = (provider.requestPurchase(request) as RequestPurchaseResultPurchase).value!! + assertNotEquals(first.id, second.id) + assertNotEquals(first.purchaseToken, second.purchaseToken) + } +} diff --git a/packages/google/compatibility/community-provider/provider/src/test/java/community/fixture/MissingCapabilityTest.kt b/packages/google/compatibility/community-provider/provider/src/test/java/community/fixture/MissingCapabilityTest.kt new file mode 100644 index 000000000..0c949b45e --- /dev/null +++ b/packages/google/compatibility/community-provider/provider/src/test/java/community/fixture/MissingCapabilityTest.kt @@ -0,0 +1,10 @@ +package community.fixture + +import dev.hyo.openiap.conformance.StoreCapability + +class MissingCapabilityTest : FixtureConformanceTest() { + override suspend fun triggerCapability(capability: StoreCapability) { + if (capability == StoreCapability.SubscriptionBillingIssue) error("Declared capability is unimplemented") + super.triggerCapability(capability) + } +} diff --git a/packages/google/compatibility/community-provider/second-provider/build.gradle.kts b/packages/google/compatibility/community-provider/second-provider/build.gradle.kts new file mode 100644 index 000000000..b9d0b6c5e --- /dev/null +++ b/packages/google/compatibility/community-provider/second-provider/build.gradle.kts @@ -0,0 +1,2 @@ +plugins { id("com.android.library") } +android { namespace = "community.other"; compileSdk = 36; defaultConfig { minSdk = 23 } } diff --git a/packages/google/compatibility/community-provider/second-provider/src/main/AndroidManifest.xml b/packages/google/compatibility/community-provider/second-provider/src/main/AndroidManifest.xml new file mode 100644 index 000000000..6220a0128 --- /dev/null +++ b/packages/google/compatibility/community-provider/second-provider/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/community-provider/settings.gradle.kts b/packages/google/compatibility/community-provider/settings.gradle.kts new file mode 100644 index 000000000..3db4b5c18 --- /dev/null +++ b/packages/google/compatibility/community-provider/settings.gradle.kts @@ -0,0 +1,10 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven { url = uri(providers.gradleProperty("openIapRepository").get()) } + google(); mavenCentral() + } +} +rootProject.name = "independent-openiap-provider" +include(":provider", ":host", ":second-provider", ":vendor-sdk") diff --git a/packages/google/compatibility/community-provider/vendor-sdk/build.gradle.kts b/packages/google/compatibility/community-provider/vendor-sdk/build.gradle.kts new file mode 100644 index 000000000..e3fcae1a8 --- /dev/null +++ b/packages/google/compatibility/community-provider/vendor-sdk/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { `java-library`; `maven-publish` } +java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } +group = "community.fixture" +version = "1.0.0" +dependencies { api("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") } +publishing { publications { create("vendor") { from(components["java"]) } } } diff --git a/packages/google/compatibility/community-provider/vendor-sdk/src/main/java/community/fixture/vendor/FixtureVendorSdk.java b/packages/google/compatibility/community-provider/vendor-sdk/src/main/java/community/fixture/vendor/FixtureVendorSdk.java new file mode 100644 index 000000000..dafdbce83 --- /dev/null +++ b/packages/google/compatibility/community-provider/vendor-sdk/src/main/java/community/fixture/vendor/FixtureVendorSdk.java @@ -0,0 +1,9 @@ +package community.fixture.vendor; + +public final class FixtureVendorSdk { + public static String productDescription() { + return "Transitive vendor SDK with JVM date " + new kotlinx.datetime.LocalDate(2024, 1, 2); + } + + private FixtureVendorSdk() {} +} diff --git a/packages/google/compatibility/store-plugin-mixed/app/build.gradle b/packages/google/compatibility/store-plugin-mixed/app/build.gradle new file mode 100644 index 000000000..dd98c3d62 --- /dev/null +++ b/packages/google/compatibility/store-plugin-mixed/app/build.gradle @@ -0,0 +1,32 @@ +// A mixed-flavor app: official platform flavors plus the community provider. +// The two properties select the provider flavor only; each official flavor +// keeps linking its own store. +plugins { + id 'com.android.application' +} + +android { + namespace 'dev.hyo.openiap.fixture.mixed' + compileSdk 36 + defaultConfig { + minSdk 24 + } + flavorDimensions = ['platform'] + productFlavors { + play { dimension 'platform' } + horizon { dimension 'platform' } + provider { dimension 'platform' } + } +} + +dependencies { + playImplementation 'io.github.hyochan.openiap:openiap-google:3.6.1' + horizonImplementation 'io.github.hyochan.openiap:openiap-google-horizon:3.6.1' + providerImplementation 'io.github.hyochan.openiap:openiap-google:3.6.1' +} + +apply from: rootProject.file('../store-plugin/print-stores.gradle') +// The store in the task name is what the resolver reads, as with assemble. +openIapFixturePrint('printPlayStores', 'playDebugRuntimeClasspath') +openIapFixturePrint('printHorizonStores', 'horizonDebugRuntimeClasspath') +openIapFixturePrint('printProviderStores', 'providerDebugRuntimeClasspath') diff --git a/packages/google/compatibility/store-plugin-mixed/app/src/main/AndroidManifest.xml b/packages/google/compatibility/store-plugin-mixed/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/packages/google/compatibility/store-plugin-mixed/app/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/google/compatibility/store-plugin-mixed/build.gradle b/packages/google/compatibility/store-plugin-mixed/build.gradle new file mode 100644 index 000000000..52af1360d --- /dev/null +++ b/packages/google/compatibility/store-plugin-mixed/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'com.android.application' version '8.13.2' apply false +} diff --git a/packages/google/compatibility/store-plugin-mixed/gradle.properties b/packages/google/compatibility/store-plugin-mixed/gradle.properties new file mode 100644 index 000000000..743ad0363 --- /dev/null +++ b/packages/google/compatibility/store-plugin-mixed/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +kotlin.code.style=official diff --git a/packages/google/compatibility/store-plugin-mixed/settings.gradle b/packages/google/compatibility/store-plugin-mixed/settings.gradle new file mode 100644 index 000000000..15d2e3969 --- /dev/null +++ b/packages/google/compatibility/store-plugin-mixed/settings.gradle @@ -0,0 +1,26 @@ +// A mixed-flavor app: its own platform flavors, one of them the community +// provider, with the plugin built from source. +pluginManagement { + includeBuild('../../gradle-plugin') + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id 'io.github.hyochan.openiap' +} + +dependencyResolutionManagement { + repositories { + def fixtureRepository = providers.gradleProperty('fixtureCommunityRepository').orNull + if (fixtureRepository) maven { url = uri(fixtureRepository) } + google() + mavenCentral() + } +} + +rootProject.name = 'store-plugin-mixed-fixture' +include ':app' diff --git a/packages/google/compatibility/store-plugin/app/build.gradle b/packages/google/compatibility/store-plugin/app/build.gradle index cc6df9fb1..8e9093938 100644 --- a/packages/google/compatibility/store-plugin/app/build.gradle +++ b/packages/google/compatibility/store-plugin/app/build.gradle @@ -29,7 +29,9 @@ android { dependencies { implementation 'io.github.hyochan.openiap:openiap-google:3.6.1' - implementation 'io.github.hyochan:kmp-iap:3.6.1' + if (!project.findProperty('fixtureCommunityRepository') || project.findProperty('fixtureKmpVersion')) { + implementation "io.github.hyochan:kmp-iap:${project.findProperty('fixtureKmpVersion') ?: '3.6.1'}" + } } apply from: rootProject.file('print-stores.gradle') diff --git a/packages/google/compatibility/store-plugin/flavored/build.gradle b/packages/google/compatibility/store-plugin/flavored/build.gradle index 30e2e6b75..4aaf3ed3b 100644 --- a/packages/google/compatibility/store-plugin/flavored/build.gradle +++ b/packages/google/compatibility/store-plugin/flavored/build.gradle @@ -15,6 +15,9 @@ android { play { dimension 'platform' } horizon { dimension 'platform' } amazon { dimension 'platform' } + if (project.findProperty('fixtureCommunityRepository')) { + provider { dimension 'platform' } + } } } @@ -27,7 +30,12 @@ dependencies { horizonImplementation 'io.github.hyochan.openiap:openiap-google-horizon:3.6.1' amazonImplementation 'io.github.hyochan.openiap:openiap-google-amazon:3.6.1' } - implementation 'io.github.hyochan:kmp-iap:3.6.1' + if (project.findProperty('fixtureCommunityRepository')) { + providerImplementation 'io.github.hyochan.openiap:openiap-google:3.6.1' + } + if (!project.findProperty('fixtureCommunityRepository') || project.findProperty('fixtureKmpVersion')) { + implementation "io.github.hyochan:kmp-iap:${project.findProperty('fixtureKmpVersion') ?: '3.6.1'}" + } } apply from: rootProject.file('print-stores.gradle') @@ -35,3 +43,6 @@ apply from: rootProject.file('print-stores.gradle') openIapFixturePrint('printFlavorStores', 'horizonDebugRuntimeClasspath') // Unit tests resolve a classpath of their own, which must link the same store. openIapFixturePrint('printFlavorUnitTestStores', 'horizonDebugUnitTestRuntimeClasspath') +if (project.findProperty('fixtureCommunityRepository')) { + openIapFixturePrint('printProviderStores', 'providerDebugRuntimeClasspath') +} diff --git a/packages/google/compatibility/store-plugin/print-stores.gradle b/packages/google/compatibility/store-plugin/print-stores.gradle index c6e9aa8d4..16b12d1a3 100644 --- a/packages/google/compatibility/store-plugin/print-stores.gradle +++ b/packages/google/compatibility/store-plugin/print-stores.gradle @@ -22,7 +22,7 @@ ext.openIapFixturePrint = { String taskName, String configurationName -> .collect { it.id } .findAll { it instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier } .collect { it.module } - .findAll { it.startsWith('openiap-google') || it.startsWith('kmp-iap-android') } + .findAll { it.startsWith('openiap-google') || it.startsWith('kmp-iap-android') || it == 'openiap-core' || it == project.findProperty('openiapProvider')?.toString()?.split(':')?.getAt(1) } .sort() } doLast { println("FIXTURE ${label}=${modules.get().join(',')}") } diff --git a/packages/google/compatibility/store-plugin/settings.gradle b/packages/google/compatibility/store-plugin/settings.gradle index 66a2e7e76..ee2ed1945 100644 --- a/packages/google/compatibility/store-plugin/settings.gradle +++ b/packages/google/compatibility/store-plugin/settings.gradle @@ -15,6 +15,8 @@ plugins { dependencyResolutionManagement { repositories { + def fixtureRepository = providers.gradleProperty('fixtureCommunityRepository').orNull + if (fixtureRepository) maven { url = uri(fixtureRepository) } google() mavenCentral() } diff --git a/packages/google/compatibility/store-plugin/unversioned/build.gradle b/packages/google/compatibility/store-plugin/unversioned/build.gradle index d496cede0..2e5e3259a 100644 --- a/packages/google/compatibility/store-plugin/unversioned/build.gradle +++ b/packages/google/compatibility/store-plugin/unversioned/build.gradle @@ -13,7 +13,10 @@ android { dependencies { implementation 'io.github.hyochan.openiap:openiap-google' - implementation 'io.github.hyochan:kmp-iap:3.6.1' + // The published kmp-iap has no provider variant, so a provider build skips it. + if (!project.findProperty('fixtureCommunityRepository') || project.findProperty('fixtureKmpVersion')) { + implementation "io.github.hyochan:kmp-iap:${project.findProperty('fixtureKmpVersion') ?: '3.6.1'}" + } } apply from: rootProject.file('print-stores.gradle') diff --git a/packages/google/core/build.gradle.kts b/packages/google/core/build.gradle.kts new file mode 100644 index 000000000..78f6b0943 --- /dev/null +++ b/packages/google/core/build.gradle.kts @@ -0,0 +1,171 @@ +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SourcesJar +import groovy.json.JsonSlurper +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.io.File + +fun locateOpeniapVersionsFile(startDir: File): File { + var current: File? = startDir + while (current != null) { + val candidate = File(current, "openiap-versions.json") + if (candidate.isFile) { + return candidate + } + current = current.parentFile + } + throw GradleException("packages/google: missing openiap-versions.json from ${startDir.absolutePath}") +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") apply false + id("org.jetbrains.kotlin.plugin.compose") apply false + id("com.vanniktech.maven.publish") apply false +} + +// AGP 9 provides built-in Kotlin, but Flutter 3.44's migrator can explicitly +// disable it while retaining AGP 9. Honor the host flag before using the AGP +// major as the default so this included build works in both migration states. +val androidGradlePluginMajor = + com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.substringBefore('.').toInt() +val builtInKotlinProperty = providers.gradleProperty("android.builtInKotlin").orNull +val usesBuiltInKotlin = + builtInKotlinProperty?.let { value -> + when (value.lowercase()) { + "true" -> true + "false" -> false + else -> throw GradleException("android.builtInKotlin must be true or false") + } + } ?: (androidGradlePluginMajor >= 9) +if (!usesBuiltInKotlin) { + pluginManager.apply("org.jetbrains.kotlin.android") +} +pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + +// Consumer examples include this module in their own Gradle builds, while KMP +// and MAUI consume the Google root as a composite build. Keep every consumer's +// intermediates under its own root build directory so sequential or parallel +// builds cannot corrupt packages/google/openiap/build. Applying the publication +// plugin there would also couple consumers to the standalone publishing setup. +val isStandaloneGoogleBuild = + gradle.parent == null && + rootProject.projectDir.canonicalFile == projectDir.parentFile.canonicalFile +if (!isStandaloneGoogleBuild) { + layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("openiap-core")) + tasks.configureEach { + // The same source project is embedded by hosts that intentionally use + // different AGP/Kotlin versions. Gradle's shared build cache can restore + // a host-incompatible classes jar even though the isolated build path is + // correct. Keep normal up-to-date checks, but never exchange cached task + // outputs between embedded consumers. + outputs.doNotCacheIf("embedded OpenIAP Google host toolchains differ") { true } + } +} +if (isStandaloneGoogleBuild) { + pluginManager.apply("com.vanniktech.maven.publish") +} + +// Read version from Gradle property first, then from monorepo root openiap-versions.json. +// Release and local publish scripts pass -P/ORG_GRADLE_PROJECT_openIapVersion; +// normal development builds use the repository SSOT file. +val versionsFile = locateOpeniapVersionsFile(projectDir) +val versionsJson = JsonSlurper().parseText(versionsFile.readText()) as Map<*, *> +val openIapVersion: String = + project.findProperty("openIapVersion")?.toString()?.takeIf { it.isNotBlank() } + ?: versionsJson["google"]?.toString()?.takeIf { it.isNotBlank() } + ?: throw GradleException("packages/google: 'google' version missing in openiap-versions.json") +val clientProtocolVersion = versionsJson["clientProtocol"]?.toString() + ?: throw GradleException("packages/google: 'clientProtocol' version missing in openiap-versions.json") +val isCentralPublishTaskRequested = + gradle.startParameter.taskNames.any { taskName -> + taskName.contains("mavenCentral", ignoreCase = true) + } + +android { + namespace = "io.github.hyochan.openiap.core" + compileSdk = 36 + defaultConfig { + minSdk = 23 + buildConfigField("String", "OPENIAP_CORE_VERSION", "\"$openIapVersion\"") + buildConfigField("String", "CLIENT_PROTOCOL_VERSION", "\"$clientProtocolVersion\"") + consumerProguardFiles("consumer-rules.pro") + } + sourceSets.named("main") { + java.setSrcDirs(listOf("../openiap/src/main/java")) + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + buildConfig = true + } + testOptions.unitTests.isIncludeAndroidResources = true +} + +extensions.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xjvm-default=all-compatibility") + } +} + +dependencies { + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0") + implementation("androidx.core:core:1.18.0") + api("androidx.lifecycle:lifecycle-runtime:2.10.0") + api("androidx.lifecycle:lifecycle-viewmodel:2.10.0") + implementation("com.google.code.gson:gson:2.14.0") + val composeUiVersion = project.findProperty("COMPOSE_UI_VERSION")?.toString() ?: "1.11.4" + api("androidx.compose.runtime:runtime:$composeUiVersion") + implementation("androidx.compose.ui:ui:$composeUiVersion") + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.16.1") + testImplementation("androidx.test:core:1.7.0") +} + +if (isStandaloneGoogleBuild) { + extensions.configure { + val groupId = project.findProperty("OPENIAP_GROUP_ID")?.toString() ?: "io.github.hyochan.openiap" + + coordinates(groupId, "openiap-core", openIapVersion) + configure(com.vanniktech.maven.publish.AndroidSingleVariantLibrary( + variant = "release", sourcesJar = SourcesJar.Sources(), javadocJar = JavadocJar.Empty(), + )) + pom { + name.set("OpenIAP Core") + description.set("Store-neutral Android provider contract and runtime") + url.set("https://github.com/hyodotdev/openiap") + } + + if (isCentralPublishTaskRequested) { + // Local compatibility tests publish without release credentials. + publishToMavenCentral() + signAllPublications() + } + + pom { + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + developers { + developer { + id.set("hyochan") + name.set("hyochan") + } + } + scm { + connection.set("scm:git:git://github.com/hyodotdev/openiap.git") + developerConnection.set("scm:git:ssh://git@github.com/hyodotdev/openiap.git") + url.set("https://github.com/hyodotdev/openiap/tree/main/packages/google") + } + } + } +} diff --git a/packages/google/core/consumer-rules.pro b/packages/google/core/consumer-rules.pro new file mode 100644 index 000000000..a37e1a5c9 --- /dev/null +++ b/packages/google/core/consumer-rules.pro @@ -0,0 +1,3 @@ +# Keep factories named by manifest metadata, including external packages. +-keep class * implements dev.hyo.openiap.OpenIapProviderFactory { public (); } +-keep public class dev.hyo.openiap.listener.** { *; } diff --git a/packages/google/core/src/main/AndroidManifest.xml b/packages/google/core/src/main/AndroidManifest.xml new file mode 100644 index 000000000..fbb2ef838 --- /dev/null +++ b/packages/google/core/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/google/core/src/test/java/community/fixture/DiscoveryFactory.kt b/packages/google/core/src/test/java/community/fixture/DiscoveryFactory.kt new file mode 100644 index 000000000..113a3d079 --- /dev/null +++ b/packages/google/core/src/test/java/community/fixture/DiscoveryFactory.kt @@ -0,0 +1,20 @@ +package community.fixture + +import android.content.Context +import dev.hyo.openiap.OpenIapProvider +import dev.hyo.openiap.OpenIapProviderFactory +import dev.hyo.openiap.OpenIapProtocol + +class DiscoveryFactory : OpenIapProviderFactory { + override val storeId = "fixture" + override val coreVersion get() = OpenIapProvider.coreVersion + override val clientProtocolVersion = "0.2.0" + override fun create(context: Context): OpenIapProtocol = throw UnsupportedOperationException("Discovery only") +} + +class NoDefaultConstructor(private val name: String) : OpenIapProviderFactory { + override val storeId = "fixture" + override val coreVersion get() = OpenIapProvider.coreVersion + override val clientProtocolVersion = "0.2.0" + override fun create(context: Context): OpenIapProtocol = throw UnsupportedOperationException(name) +} diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/utils/ActivityUtilsTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/ActivityUtilsTest.kt similarity index 100% rename from packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/utils/ActivityUtilsTest.kt rename to packages/google/core/src/test/java/dev/hyo/openiap/ActivityUtilsTest.kt diff --git a/packages/google/core/src/test/java/dev/hyo/openiap/IapkitResultStoreIdentityTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/IapkitResultStoreIdentityTest.kt new file mode 100644 index 000000000..7245685d7 --- /dev/null +++ b/packages/google/core/src/test/java/dev/hyo/openiap/IapkitResultStoreIdentityTest.kt @@ -0,0 +1,93 @@ +package dev.hyo.openiap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class IapkitResultStoreIdentityTest { + @Test fun `legacy official results derive and round trip their store id`() { + for ((store, id) in listOf( + IapStore.Apple to "apple", + IapStore.Google to "play", + IapStore.Horizon to "horizon", + IapStore.Amazon to "amazon", + )) { + val result = RequestVerifyPurchaseWithIapkitResult(true, IapkitPurchaseState.Entitled, store) + assertEquals(id, result.storeId) + val roundTripped = RequestVerifyPurchaseWithIapkitResult.fromJson(result.toJson()) + assertEquals(id, roundTripped.storeId) + assertEquals(store, roundTripped.store) + } + } + + @Test fun `unknown without an explicit store id fails fast`() { + assertThrows(IllegalArgumentException::class.java) { + RequestVerifyPurchaseWithIapkitResult(true, IapkitPurchaseState.Entitled, IapStore.Unknown) + } + assertThrows(IllegalArgumentException::class.java) { + RequestVerifyPurchaseWithIapkitResult(true, IapkitPurchaseState.Entitled, IapStore.Unknown, null, null) + } + assertThrows(IllegalArgumentException::class.java) { + RequestVerifyPurchaseWithIapkitResult.fromJson(mapOf("isValid" to true, "state" to "entitled", "store" to "unknown")) + } + } + + @Test fun `explicit store ids must match their store`() { + val community = RequestVerifyPurchaseWithIapkitResult(true, IapkitPurchaseState.Entitled, IapStore.Unknown, null, null, null, "community-fixture") + assertEquals("community-fixture", community.storeId) + assertEquals("community-fixture", RequestVerifyPurchaseWithIapkitResult.fromJson(community.toJson()).storeId) + for (id in listOf("unknown", "", "auto", "none", "apple", "play", "Bad id")) { + assertThrows(IllegalArgumentException::class.java) { + RequestVerifyPurchaseWithIapkitResult(true, IapkitPurchaseState.Entitled, IapStore.Unknown, null, null, null, id) + } + assertThrows(IllegalArgumentException::class.java) { + RequestVerifyPurchaseWithIapkitResult.fromJson(mapOf("isValid" to true, "state" to "entitled", "store" to "unknown", "storeId" to id)) + } + } + assertThrows(IllegalArgumentException::class.java) { + RequestVerifyPurchaseWithIapkitResult(true, IapkitPurchaseState.Entitled, IapStore.Google, null, null, null, "amazon") + } + assertThrows(IllegalArgumentException::class.java) { + RequestVerifyPurchaseWithIapkitResult.fromJson(mapOf("isValid" to true, "state" to "entitled", "store" to "google", "storeId" to "amazon")) + } + } + + @Test fun `copy keeps store and store id consistent`() { + val google = RequestVerifyPurchaseWithIapkitResult(true, IapkitPurchaseState.Entitled, IapStore.Google) + assertEquals("play", google.storeId) + val amazon = google.copy(store = IapStore.Amazon) + assertEquals(IapStore.Amazon, amazon.store) + assertEquals("amazon", amazon.storeId) + assertEquals("play", google.copy().storeId) + assertThrows(IllegalArgumentException::class.java) { google.copy(store = IapStore.Unknown) } + val community = google.copy(store = IapStore.Unknown, storeId = "community-fixture") + assertEquals("community-fixture", community.storeId) + assertThrows(IllegalArgumentException::class.java) { google.copy(store = IapStore.Amazon, storeId = "play") } + } + + @Test fun `copy keeps a community store id when the store is unchanged`() { + val community = RequestVerifyPurchaseWithIapkitResult(true, IapkitPurchaseState.Entitled, IapStore.Unknown, null, null, null, "amazon-example") + val updated = community.copy(isValid = false) + assertEquals(false, updated.isValid) + assertEquals(IapStore.Unknown, updated.store) + assertEquals("amazon-example", updated.storeId) + } + + @Test fun `descriptor rejects missing or blank identities`() { + val valid = mapOf( + "capabilities" to listOf("pendingPurchases"), + "clientProtocolVersion" to "0.2.0", + "coreVersion" to "3.6.2", + "platform" to "android", + "storeId" to "community-fixture", + ) + assertEquals("community-fixture", StoreProviderDescriptor.fromJson(valid).storeId) + for (field in listOf("storeId", "coreVersion", "clientProtocolVersion")) { + assertThrows(IllegalArgumentException::class.java) { StoreProviderDescriptor.fromJson(valid - field) } + for (blank in listOf("", " ")) { + assertThrows(IllegalArgumentException::class.java) { StoreProviderDescriptor.fromJson(valid + (field to blank)) } + } + } + assertThrows(IllegalArgumentException::class.java) { StoreProviderDescriptor.fromJson(valid - "capabilities") } + } +} diff --git a/packages/google/core/src/test/java/dev/hyo/openiap/InitConnectionForProviderTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/InitConnectionForProviderTest.kt new file mode 100644 index 000000000..948bbc179 --- /dev/null +++ b/packages/google/core/src/test/java/dev/hyo/openiap/InitConnectionForProviderTest.kt @@ -0,0 +1,62 @@ +package dev.hyo.openiap + +import android.content.Context +import android.os.Bundle +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +class PlayDiscoveryFactory : OpenIapProviderFactory { + override val storeId = "play" + override val coreVersion get() = OpenIapProvider.coreVersion + override val clientProtocolVersion = "0.2.0" + override fun create(context: Context): OpenIapProtocol = throw UnsupportedOperationException("Discovery only") +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class InitConnectionForProviderTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun metadata(className: String?) { + val info = context.packageManager.getApplicationInfo(context.packageName, 0) + info.metaData = Bundle().apply { className?.let { putString(OpenIapProvider.METADATA_KEY, it) } } + shadowOf(context.packageManager).installPackage( + android.content.pm.PackageInfo().apply { + packageName = context.packageName + applicationInfo = info + }, + ) + } + + @Test fun `non-Play provider failure names its store`() { + metadata("community.fixture.DiscoveryFactory") + + val error = OpenIapError.InitConnection.forProvider(context) + + assertEquals(OpenIapError.InitConnection.CODE, error.code) + assertTrue(error.message.contains("fixture")) + } + + @Test fun `Play provider failure keeps the plain message`() { + metadata(PlayDiscoveryFactory::class.java.name) + + val error = OpenIapError.InitConnection.forProvider(context) + + assertSame(OpenIapError.InitConnection, error) + } + + @Test fun `unreadable provider keeps the plain message`() { + metadata(null) + + val error = OpenIapError.InitConnection.forProvider(context) + + assertSame(OpenIapError.InitConnection, error) + } +} diff --git a/packages/google/core/src/test/java/dev/hyo/openiap/OfferCodeRedemptionTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/OfferCodeRedemptionTest.kt new file mode 100644 index 000000000..f932095e0 --- /dev/null +++ b/packages/google/core/src/test/java/dev/hyo/openiap/OfferCodeRedemptionTest.kt @@ -0,0 +1,75 @@ +package dev.hyo.openiap + +import android.app.Activity +import dev.hyo.openiap.utils.redeemOfferCode +import java.lang.reflect.Proxy +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class OfferCodeRedemptionTest { + @Test + fun `Activity calls the public override without optional handlers`() = runBlocking { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + for (result in listOf(true, false)) { + val calls = mutableListOf() + val provider = provider { method, args -> + calls.add(method) + assertSame(activity, args?.first()) + when (method) { + "setActivity" -> null + "openRedeemOfferCode" -> result + else -> error("Unexpected call: $method") + } + } + assertEquals(result, redeemOfferCode(provider, activity)) + assertEquals(listOf("setActivity", "openRedeemOfferCode"), calls) + } + val expected = OpenIapError.FeatureNotSupported() + val failing = provider { method, args -> + if (method == "setActivity") null else { + @Suppress("UNCHECKED_CAST") + val continuation = args!!.last() as Continuation + continuation.resumeWith(Result.failure(expected)) + COROUTINE_SUSPENDED + } + } + assertSame(expected, assertThrows(OpenIapError.FeatureNotSupported::class.java) { + runBlocking { redeemOfferCode(failing, activity) } + }) + } + + @Test + fun `no Activity preserves legacy success false and typed failure`() = runBlocking { + for (result in listOf(true, false)) { + val handler: MutationOpenRedeemOfferCodeAndroidHandler = { result } + val provider = provider { method, _ -> + check(method == "getMutationHandlers") + MutationHandlers(openRedeemOfferCodeAndroid = handler) + } + assertEquals(result, redeemOfferCode(provider, null)) + } + for (handler in listOf(null, { throw OpenIapError.MissingCurrentActivity })) { + val provider = provider { method, _ -> + check(method == "getMutationHandlers") + MutationHandlers(openRedeemOfferCodeAndroid = handler) + } + assertSame(OpenIapError.MissingCurrentActivity, assertThrows(OpenIapError.MissingCurrentActivity::class.java) { + runBlocking { redeemOfferCode(provider, null) } + }) + } + } + + private fun provider(invoke: (String, Array?) -> Any?): OpenIapProtocol = + Proxy.newProxyInstance(OpenIapProtocol::class.java.classLoader, arrayOf(OpenIapProtocol::class.java)) { _, method, args -> + invoke(method.name, args) + } as OpenIapProtocol +} diff --git a/packages/google/core/src/test/java/dev/hyo/openiap/OpenIapProviderTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/OpenIapProviderTest.kt new file mode 100644 index 000000000..bb8ec5e27 --- /dev/null +++ b/packages/google/core/src/test/java/dev/hyo/openiap/OpenIapProviderTest.kt @@ -0,0 +1,139 @@ +package dev.hyo.openiap + +import android.content.Context +import android.os.Bundle +import androidx.test.core.app.ApplicationProvider +import io.github.hyochan.openiap.core.BuildConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class OpenIapProviderTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun metadata(className: String?) { + val info = context.packageManager.getApplicationInfo(context.packageName, 0) + info.metaData = Bundle().apply { className?.let { putString(OpenIapProvider.METADATA_KEY, it) } } + shadowOf(context.packageManager).installPackage( + android.content.pm.PackageInfo().apply { + packageName = context.packageName + applicationInfo = info + }, + ) + } + + @Test fun `published Kotlin default argument bridges remain linkable`() { + val defaults = Class.forName("dev.hyo.openiap.OpenIapProtocol\$DefaultImpls") + defaults.getDeclaredMethod( + "showInAppMessages\$default", + OpenIapProtocol::class.java, + android.app.Activity::class.java, + InAppMessageParamsAndroid::class.java, + kotlin.coroutines.Continuation::class.java, + Int::class.javaPrimitiveType, + Any::class.java, + ) + defaults.getDeclaredMethod( + "createBillingProgramReportingDetails\$default", + OpenIapProtocol::class.java, + BillingProgramAndroid::class.java, + DeveloperBillingTypeAndroid::class.java, + kotlin.coroutines.Continuation::class.java, + Int::class.javaPrimitiveType, + Any::class.java, + ) + } + + @Test fun `stable provider works on newer core in its major`() { + OpenIapProvider.validate("fake-store", "3.6.2", "3.7.0") + OpenIapProvider.validate("play", "3.6.2", "3.6.2") + } + + @Test fun `invalid ids and incompatible cores fail with a developer error`() { + for ((id, built, runtime) in listOf( + Triple("auto", "3.6.2", "3.6.2"), + Triple("google", "3.6.2", "3.6.2"), + Triple("Bad Store", "3.6.2", "3.6.2"), + Triple("fake", "4.0.0", "3.6.2"), + Triple("fake", "3.7.0", "3.6.2"), + Triple("fake", "bad", "3.6.2"), + Triple("fake", "3.6.2-rc.1", "3.6.2"), + )) { + val error = + assertThrows(OpenIapError.ProviderConfiguration::class.java) { + OpenIapProvider.validate(id, built, runtime) + } + assertEquals(ErrorCode.DeveloperError.rawValue, error.code) + } + } + + @Test fun `descriptor validates the native and protocol contracts separately`() { + val descriptor = community.fixture.DiscoveryFactory().descriptor + OpenIapProvider.validate(descriptor) + assertEquals(IapPlatform.Android, descriptor.platform) + for (invalid in listOf( + descriptor.copy(platform = IapPlatform.Ios), + descriptor.copy(clientProtocolVersion = "0.1.1"), + descriptor.copy(clientProtocolVersion = "0.3.0"), + descriptor.copy(clientProtocolVersion = "bad"), + descriptor.copy(coreVersion = "999.0.0"), + )) { + assertThrows(OpenIapError.ProviderConfiguration::class.java) { OpenIapProvider.validate(invalid) } + } + } + + @Test fun `descriptor mismatch messages name the failing contract`() { + val descriptor = community.fixture.DiscoveryFactory().descriptor + fun messageFor(invalid: StoreProviderDescriptor): String = + assertThrows(OpenIapError.ProviderConfiguration::class.java) { OpenIapProvider.validate(invalid) }.message + assertEquals( + "Provider 'fixture' requires Client Protocol 0.3.0; this app links ${BuildConfig.CLIENT_PROTOCOL_VERSION}. Update the provider or the OpenIAP runtime to a matching protocol.", + messageFor(descriptor.copy(clientProtocolVersion = "0.3.0")), + ) + assertEquals( + "Invalid Client Protocol version 'bad' for provider 'fixture'. Use a complete semantic version.", + messageFor(descriptor.copy(clientProtocolVersion = "bad")), + ) + assertEquals( + "Provider 'fixture' requires openiap-core 999.0.0; this app links ${OpenIapProvider.coreVersion}. Use a compatible provider or core version.", + messageFor(descriptor.copy(coreVersion = "999.0.0")), + ) + } + + @Test fun `core mismatch messages keep the core wording`() { + val tooNew = assertThrows(OpenIapError.ProviderConfiguration::class.java) { + OpenIapProvider.validate("fake", "3.7.0", "3.6.2") + } + assertEquals( + "Provider 'fake' requires openiap-core 3.7.0; this app links 3.6.2. Use a compatible provider or core version.", + tooNew.message, + ) + val malformed = assertThrows(OpenIapError.ProviderConfiguration::class.java) { + OpenIapProvider.validate("fake", "bad", "3.6.2") + } + assertEquals( + "Invalid core version 'bad' for provider 'fake'. Use a complete semantic version.", + malformed.message, + ) + } + + @Test fun `factory is discovered outside the core package`() { + metadata("community.fixture.DiscoveryFactory") + assertEquals("fixture", OpenIapProvider.factory(context).storeId) + } + + @Test fun `missing incorrect and uninstantiable factory classes fail clearly`() { + for (name in listOf(null, "community.missing.Factory", "java.lang.String", "community.fixture.NoDefaultConstructor")) { + metadata(name) + val error = assertThrows(OpenIapError.ProviderConfiguration::class.java) { OpenIapProvider.factory(context) } + assertTrue(error.message.isNotBlank()) + } + } +} diff --git a/packages/google/core/src/test/java/dev/hyo/openiap/OpenIapStoreProviderDispatchTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/OpenIapStoreProviderDispatchTest.kt new file mode 100644 index 000000000..c50a7eff7 --- /dev/null +++ b/packages/google/core/src/test/java/dev/hyo/openiap/OpenIapStoreProviderDispatchTest.kt @@ -0,0 +1,67 @@ +package dev.hyo.openiap + +import dev.hyo.openiap.store.OpenIapStore +import java.lang.reflect.Proxy +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenIapStoreProviderDispatchTest { + @Test + fun `required operations reach providers without optional dispatch tables`() = runBlocking { + val queries = mutableListOf?>() + val management = mutableListOf() + var restores = 0 + var requests = 0 + var finishes = 0 + val purchase = PurchaseAndroid.fromJson(mapOf( + "id" to "opaque", "productId" to "product", "purchaseToken" to "token", + "store" to "unknown", "storeId" to "community-fixture", "quantity" to 1, + "purchaseState" to "purchased", "transactionDate" to 1.0, "isAutoRenewing" to false, + )) + val request: MutationRequestPurchaseHandler = { requests++; RequestPurchaseResultPurchase(purchase) } + val finish: MutationFinishTransactionHandler = { value, consumable -> + assertEquals(purchase, value) + assertEquals(true, consumable) + finishes++ + Unit + } + val active: QueryGetActiveSubscriptionsHandler = { queries.add(it); emptyList() } + val hasActive: QueryHasActiveSubscriptionsHandler = { queries.add(it); true } + val restore: MutationRestorePurchasesHandler = { restores++; Unit } + val deepLink: MutationDeepLinkToSubscriptionsHandler = { management.add(it); Unit } + val provider = Proxy.newProxyInstance( + OpenIapProtocol::class.java.classLoader, + arrayOf(OpenIapProtocol::class.java), + ) { _, method, _ -> + when (method.name) { + "getGetActiveSubscriptions" -> active + "getHasActiveSubscriptions" -> hasActive + "getRestorePurchases" -> restore + "getDeepLinkToSubscriptions" -> deepLink + "getRequestPurchase" -> request + "getFinishTransaction" -> finish + "addPurchaseUpdateListener", "addPurchaseErrorListener", "addConnectionStateListener" -> null + else -> error("Unexpected call: ${method.name}") + } + } as OpenIapProtocol + val store = OpenIapStore(provider) + + assertTrue(store.getActiveSubscriptions(listOf("subscription")).isEmpty()) + assertTrue(store.hasActiveSubscriptions(null)) + store.restorePurchases() + store.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = "subscription")) + store.requestPurchase(RequestPurchaseProps.fromJson(mapOf( + "type" to "in-app", "requestPurchase" to mapOf("google" to mapOf("skus" to listOf("product"))), + ))) + store.finishTransaction(purchase, true) + store.finishTransaction(purchase, true) + + assertEquals(listOf(listOf("subscription"), null), queries) + assertEquals(1, restores) + assertEquals("subscription", management.single()?.skuAndroid) + assertEquals(1, requests) + assertEquals(1, finishes) + } +} diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/store/OpenIapStorePurchaseRequestResolverTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/OpenIapStorePurchaseRequestResolverTest.kt similarity index 100% rename from packages/google/openiap/src/test/java/dev/hyo/openiap/store/OpenIapStorePurchaseRequestResolverTest.kt rename to packages/google/core/src/test/java/dev/hyo/openiap/OpenIapStorePurchaseRequestResolverTest.kt diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/utils/OwnerScopedValueBindingTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/OwnerScopedValueBindingTest.kt similarity index 100% rename from packages/google/openiap/src/test/java/dev/hyo/openiap/utils/OwnerScopedValueBindingTest.kt rename to packages/google/core/src/test/java/dev/hyo/openiap/OwnerScopedValueBindingTest.kt diff --git a/packages/google/core/src/test/java/dev/hyo/openiap/StoreIdentityTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/StoreIdentityTest.kt new file mode 100644 index 000000000..dd233dfc0 --- /dev/null +++ b/packages/google/core/src/test/java/dev/hyo/openiap/StoreIdentityTest.kt @@ -0,0 +1,60 @@ +package dev.hyo.openiap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class StoreIdentityTest { + private fun payload(store: String) = + mapOf( + "store" to store, + "id" to "txn", + "productId" to "sku", + "quantity" to 1, + "isAutoRenewing" to false, + "purchaseState" to "purchased", + "transactionDate" to 1.0, + ) + + @Test fun `legacy official identities decode and round trip`() { + for ((store, id) in mapOf("apple" to "apple", "google" to "play", "horizon" to "horizon", "amazon" to "amazon")) { + val purchase = PurchaseAndroid.fromJson(payload(store)) + assertEquals(id, purchase.storeId) + assertEquals(id, PurchaseAndroid.fromJson(purchase.toJson()).storeId) + } + } + + @Test fun `community identity round trips and invalid identities fail`() { + val json = payload("unknown") + ("storeId" to "community-fixture") + assertEquals("community-fixture", PurchaseAndroid.fromJson(PurchaseAndroid.fromJson(json).toJson()).storeId) + for (id in listOf(null, "", "auto", "none", "unknown", "apple", "play", "google", "amazon", "horizon", "Bad id", "store\n", 42)) { + assertThrows(IllegalArgumentException::class.java) { PurchaseAndroid.fromJson(payload("unknown") + ("storeId" to id)) } + } + assertThrows(IllegalArgumentException::class.java) { PurchaseAndroid.fromJson(payload("google") + ("storeId" to "other")) } + } + + @Test fun `result copies preserve additive values and compare identities`() { + val original = + RequestVerifyPurchaseWithIapkitResult.fromJson( + mapOf( + "isValid" to true, + "state" to "entitled", + "store" to "unknown", + "clientPayload" to mapOf("body" to "signed", "format" to "json", "updatedAt" to 1.0, "version" to 1.0), + "storeId" to "community-fixture", + "productId" to "sku", + "environment" to "sandbox", + ), + ) + val copy = original.copy(isValid = false) + assertEquals(original.storeId, copy.storeId) + assertEquals(original.productId, copy.productId) + assertEquals(original.environment, copy.environment) + assertEquals(original.clientPayload, copy.clientPayload) + assertEquals(original, original.copy()) + assertEquals(original.hashCode(), original.copy().hashCode()) + val other = RequestVerifyPurchaseWithIapkitResult.fromJson(original.toJson() + ("storeId" to "other-store")) + assertNotEquals(original, other) + } +} diff --git a/packages/google/gradle-plugin/build.gradle.kts b/packages/google/gradle-plugin/build.gradle.kts index 418139af0..b0f2783df 100644 --- a/packages/google/gradle-plugin/build.gradle.kts +++ b/packages/google/gradle-plugin/build.gradle.kts @@ -35,7 +35,7 @@ gradlePlugin { id = "io.github.hyochan.openiap" implementationClass = "dev.hyo.openiap.gradle.OpenIapPlugin" displayName = "OpenIAP store selection" - description = "Links the Play, Horizon, or Amazon build of openiap-google and kmp-iap by the OpenIAP store rule." + description = "Selects an official or community Android store provider for OpenIAP consumers." } } } diff --git a/packages/google/gradle-plugin/src/main/resources/dev/hyo/openiap/gradle/openiap-store-plugin.gradle b/packages/google/gradle-plugin/src/main/resources/dev/hyo/openiap/gradle/openiap-store-plugin.gradle index a2cb6fc8b..430d6db2f 100644 --- a/packages/google/gradle-plugin/src/main/resources/dev/hyo/openiap/gradle/openiap-store-plugin.gradle +++ b/packages/google/gradle-plugin/src/main/resources/dev/hyo/openiap/gradle/openiap-store-plugin.gradle @@ -25,13 +25,14 @@ def openIapUseStore = { String store, Collection targets = null -> ) } // useTarget needs a version; a BOM's does not carry over to the store's artifact. + def artifact = store in openIapArtifactStores.values() ? "openiap-google-${store}" : 'openiap-core' def version = details.requested.version if (!version) { throw new GradleException( - "openiap: openiap-google is declared without a version but this build's store is ${store}; declare a version so the plugin can link openiap-google-${store}" + "openiap: openiap-google is declared without a version but this build's store is ${store}; declare a version so the plugin can link ${artifact}" ) } - details.useTarget(group: openIapGroup, name: "openiap-google-${store}", version: version) + details.useTarget(group: openIapGroup, name: artifact, version: version) details.because("openiap: store=${store}") } } @@ -56,7 +57,7 @@ def openIapRequestedPlatform = { flavor -> if (request == null) { return null } - return ([request.requested] + request.fallbacks).find { it in openIapArtifactStores.values() } + return ([request.requested] + request.fallbacks).find { it in openIapArtifactStores.values() || it == 'provider' } } ['com.android.application', 'com.android.library', 'com.android.dynamic-feature', 'com.android.test'].each { id -> @@ -68,20 +69,36 @@ def openIapRequestedPlatform = { flavor -> if (flavor in openIapArtifactStores.values()) { def components = [variant] + variant.nestedComponents openIapUseStore(flavor, components.collectMany { [it.runtimeConfiguration, it.compileConfiguration] }) + } else if (flavor == 'provider') { + def components = [variant] + variant.nestedComponents + def targets = components.collectMany { [it.runtimeConfiguration, it.compileConfiguration] } + targets.each { configuration -> + configuration.incoming.beforeResolve { + if (!openIapResolveFlavorProvider('openiap').provider) { + throw new GradleException('openiap: the provider platform flavor requires openiapStore and openiapProvider') + } + } + } + openIapUseStore('provider', targets) } } androidComponents.finalizeDsl { android -> if (android.flavorDimensions.contains('platform')) { + if (android.productFlavors.findByName('provider') != null && project.findProperty('openiapProvider')) { + def resolution = openIapResolveFlavorProvider('openiap') + dependencies.add('providerImplementation', resolution.provider) + } if (project.findProperty('openiapStore')) { logger.lifecycle("openiap: ${project.path} declares platform flavors, so openiapStore does not apply to it") } return } - def store = openIapResolveStore('openiap').store + def resolution = openIapResolveStore('openiap') + def store = resolution.store // A platform the module or a flavor already asks for must match the store. ([android.defaultConfig] + android.productFlavors.toList()).each { flavor -> def requested = openIapRequestedPlatform(flavor) - if (requested != null && requested != store) { + if (requested != null && requested != resolution.platform) { def owner = flavor.is(android.defaultConfig) ? project.path : "${project.path} flavor ${flavor.name}" throw new GradleException( "openiap: ${owner} sets missingDimensionStrategy platform=${requested} but this build's store is ${store}; remove the strategy and let the plugin set it, or set openiapStore=${requested}" @@ -89,23 +106,26 @@ def openIapRequestedPlatform = { flavor -> } } // kmp-iap publishes one Android variant per store in this dimension. - android.defaultConfig.missingDimensionStrategy('platform', store) + android.defaultConfig.missingDimensionStrategy('platform', resolution.platform) + if (resolution.provider) dependencies.add('implementation', resolution.provider) openIapUseStore(store) } } } pluginManager.withPlugin('com.android.kotlin.multiplatform.library') { - def store = openIapResolveStore('openiap').store + def resolution = openIapResolveStore('openiap') + def store = resolution.store kotlin.targets.configureEach { target -> // AGP 9 removed dependencyVariantSelection; localDependencySelection replaces it. if (target.hasProperty('localDependencySelection')) { - target.localDependencySelection.productFlavorDimension('platform') { spec -> spec.selectFrom.set([store]) } + target.localDependencySelection.productFlavorDimension('platform') { spec -> spec.selectFrom.set([resolution.platform]) } } else if (target.hasProperty('dependencyVariantSelection')) { - target.dependencyVariantSelection.productFlavors.put('platform', [store]) + target.dependencyVariantSelection.productFlavors.put('platform', [resolution.platform]) } else if (target.platformType.name() == 'androidJvm') { logger.warn("openiap: ${project.path} cannot select kmp-iap's ${store} variant on this Android Gradle Plugin; select the platform flavor yourself") } } + if (resolution.provider) dependencies.add('androidMainImplementation', resolution.provider) openIapUseStore(store) } diff --git a/packages/google/gradle/openiap-store.gradle b/packages/google/gradle/openiap-store.gradle index b087ad079..159f997d9 100644 --- a/packages/google/gradle/openiap-store.gradle +++ b/packages/google/gradle/openiap-store.gradle @@ -16,10 +16,21 @@ // builds another store (an abbreviation such as aHR) fails once the graph is ready. ext.openIapStoreAliases = [ - play: 'play', google: 'play', gplay: 'play', googleplay: 'play', 'google-play': 'play', gms: 'play', - horizon: 'horizon', meta: 'horizon', quest: 'horizon', - amazon: 'amazon', fire: 'amazon', fireos: 'amazon', 'fire-os': 'amazon', - none: 'none', auto: 'auto', + 'play': 'play', + 'google': 'play', + 'gplay': 'play', + 'googleplay': 'play', + 'google-play': 'play', + 'gms': 'play', + 'horizon': 'horizon', + 'meta': 'horizon', + 'quest': 'horizon', + 'amazon': 'amazon', + 'fire': 'amazon', + 'fireos': 'amazon', + 'fire-os': 'amazon', + 'auto': 'auto', + 'none': 'none', ] ext.openIapNormalizeStore = { Object value, String label -> @@ -28,10 +39,9 @@ ext.openIapNormalizeStore = { Object value, String label -> return null } def store = openIapStoreAliases[key] - if (store == null) { - throw new GradleException("${label}: unknown openiapStore '${value}'; use play, horizon, amazon, auto, or none where the library allows it") - } - return store + if (store != null) return store + if (key ==~ /[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*/ && !(key in ['apple', 'unknown']) && project.findProperty('openiapProvider')) return key + throw new GradleException("${label}: unknown openiapStore '${value}'; use play, horizon, amazon, auto, or none where the library allows it, or supply openiapProvider for a lowercase community store id") } // assembleHorizonRelease -> [assemble, horizon, release] @@ -327,6 +337,53 @@ ext.openIapDeviceStore = { return [store: store, reason: "device ${serial}, manufacturer ${manufacturer ?: 'unknown'}"] } +// Validates openiapProvider against the explicit store, for the plain path +// and the provider platform flavor alike. Returns the coordinates, or null +// when no provider is involved. +ext.openIapCheckProvider = { String label, String store, String explicit -> + def provider = project.findProperty('openiapProvider')?.toString()?.trim() + def official = store in ['play', 'horizon', 'amazon'] + if (provider) { + if (official || store == 'none' || explicit == null) { + throw new GradleException("${label}: openiapProvider requires an explicit community openiapStore id") + } + if (!(provider ==~ /[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[0-9][A-Za-z0-9_.-]*/)) { + throw new GradleException("${label}: openiapProvider must be group:artifact:version with a fixed version") + } + // A snapshot in any letter case is a moving target, not a fixed version. + def providerVersion = provider.split(':')[2] + if (providerVersion.toLowerCase(Locale.ROOT).contains('snapshot')) { + throw new GradleException("${label}: openiapProvider must be group:artifact:version with a fixed version") + } + def module = provider.split(':').take(2).join(':') + if (module.startsWith('io.github.hyochan.openiap:openiap-')) { + throw new GradleException("${label}: openiapProvider must name a community provider, not an OpenIAP core or official artifact") + } + return provider + } + if (!official && store != 'none') { + throw new GradleException("${label}: openiapStore=${store} requires openiapProvider=group:artifact:version") + } + return null +} + +// The provider platform flavor's coordinates. The pin selects that flavor +// only, so the task-flavor rule does not apply: each official flavor of the +// same app still links its own store. +ext.openIapResolveFlavorProvider = { String label -> + if (rootProject.ext.has('openIapProviderResolution')) { + return rootProject.ext.openIapProviderResolution + } + def explicit = openIapExplicitStore(label, false) + def provider = openIapCheckProvider(label, explicit ?: 'play', explicit) + def resolution = [store: explicit, provider: provider] + rootProject.ext.openIapProviderResolution = resolution + if (provider) { + logger.quiet("${label}: provider=${provider} for the provider platform flavor") + } + return resolution +} + ext.openIapResolveStore = { String label, Map options = [:] -> def allowNone = options.allowNone == true if (rootProject.ext.has('openIapStoreResolution')) { @@ -361,8 +418,27 @@ ext.openIapResolveStore = { String label, Map options = [:] -> if (resolution == null) { resolution = [store: 'play', source: 'default', reason: 'no store signal'] } + resolution.official = resolution.store in ['play', 'horizon', 'amazon'] + resolution.platform = resolution.official ? resolution.store : 'provider' + def provider = openIapCheckProvider(label, resolution.store, explicit) + if (provider) resolution.provider = provider rootProject.ext.openIapStoreResolution = resolution logger.quiet("openiap: store=${resolution.store} (source=${resolution.source}; ${resolution.reason})") openIapVerifyTaskGraph(label, resolution) return resolution } + +// All framework wrappers use this dependency path, including embedded local builds. +ext.openIapAddStoreDependencies = { String configuration, String version, String localProject = ':openiap' -> + def resolution = openIapResolveStore('openiap', [allowNone: true]) + if (resolution.store == 'none') return + if (resolution.provider) { + def core = findProject(':openiap-core') + dependencies.add(configuration, core != null ? core : "io.github.hyochan.openiap:openiap-core:${version}") + dependencies.add(configuration, resolution.provider) + } else { + def local = findProject(localProject) + def artifact = resolution.store == 'play' ? 'openiap-google' : "openiap-google-${resolution.store}" + dependencies.add(configuration, local != null ? local : "io.github.hyochan.openiap:${artifact}:${version}") + } +} diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index 26bf09dfe..e23fff841 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -87,6 +87,7 @@ android { defaultConfig { minSdk = 23 + buildConfigField("String", "OPENIAP_CORE_VERSION", "\"$openIapVersion\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -139,7 +140,7 @@ android { // Explicit source set configuration for shared code sourceSets { named("main") { - java.srcDirs("src/main/java") + java.setSrcDirs(emptyList()) } named("play") { java.srcDirs("src/play/java") @@ -151,18 +152,15 @@ android { java.srcDirs("src/amazon/java") manifest.srcFile("src/amazon/AndroidManifest.xml") } - // src/conformanceTest/java holds the shared behavioral conformance - // suite. It is compiled into every store flavor's unit tests so a - // behavior is declared once and executed against all of them; each - // flavor supplies only a StoreConformanceAdapter from its own set. + // Every flavor runs the published store-neutral suite. named("testPlay") { - java.srcDirs("src/testPlay/java", "src/conformanceTest/java") + java.srcDirs("src/testPlay/java") } named("testHorizon") { - java.srcDirs("src/testHorizon/java", "src/conformanceTest/java") + java.srcDirs("src/testHorizon/java") } named("testAmazon") { - java.srcDirs("src/testAmazon/java", "src/conformanceTest/java") + java.srcDirs("src/testAmazon/java") } } @@ -180,6 +178,15 @@ extensions.configure { } dependencies { + api(project(":openiap-core")) + if (findProject(":openiap-conformance") != null) { + testImplementation(project(":openiap-conformance")) + } else { + val suiteSource = projectDir.resolve("../../conformance/src/spec/suite-version.mjs").readText() + val suiteVersion = Regex("SUITE_VERSION = '([^']+)'").find(suiteSource)?.groupValues?.get(1) + ?: error("Missing conformance suite version") + testImplementation("io.github.hyochan.openiap:openiap-conformance:$suiteVersion") + } val playBillingVersion = "9.1.0" val coroutinesVersion = "1.11.0" val horizonBillingCompatibilityVersion = "2.0.0" diff --git a/packages/google/openiap/src/amazon/AndroidManifest.xml b/packages/google/openiap/src/amazon/AndroidManifest.xml index c66d39574..24c5066dd 100644 --- a/packages/google/openiap/src/amazon/AndroidManifest.xml +++ b/packages/google/openiap/src/amazon/AndroidManifest.xml @@ -8,6 +8,7 @@ + val purchases = try { withContext(Dispatchers.IO) { + val androidArgs = try { + props.toAndroidPurchaseArgs() + } catch (error: IllegalArgumentException) { + emitPurchaseErrorAndThrow(OpenIapError.DeveloperError(error.message)) + } ensureRegistered() - val androidArgs = props.toAndroidPurchaseArgs() if (androidArgs.skus.isEmpty()) { emitPurchaseErrorAndThrow(OpenIapError.EmptySkuList) } @@ -856,7 +861,7 @@ class OpenIapModule( RequestPurchaseResultPurchases(purchases) } - suspend fun getAvailableItems(type: ProductQueryType): List = withContext(Dispatchers.IO) { + override suspend fun getAvailableItems(type: ProductQueryType): List = withContext(Dispatchers.IO) { requestPurchaseUpdates(reset = true).filter { purchase -> val receiptId = purchase.purchaseToken ?: purchase.id val productType = purchaseTypeByReceiptId[receiptId] @@ -1018,7 +1023,7 @@ class OpenIapModule( userChoiceBillingAndroid = userChoiceBillingAndroid, ) - suspend fun getStorefront(): String = withContext(Dispatchers.IO) { + override suspend fun getStorefront(): String = withContext(Dispatchers.IO) { try { val response = requestUserData() when (response.requestStatus) { @@ -1274,12 +1279,12 @@ class OpenIapModule( ensureRegistered() issueAmazonRequest( operation = "getUserData", - missingRequestError = { OpenIapError.InitConnection }, + missingRequestError = { OpenIapError.StoreConnectionFailure("amazon") }, expectedGeneration = operationGeneration, ) { runCatching { PurchasingService.getUserData() } .getOrElse { - throw OpenIapError.InitConnection + throw OpenIapError.StoreConnectionFailure("amazon") } } } diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapStoreFactory.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapStoreFactory.kt new file mode 100644 index 000000000..bdfc4ef66 --- /dev/null +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapStoreFactory.kt @@ -0,0 +1,12 @@ +package dev.hyo.openiap + +import android.content.Context +import io.github.hyochan.openiap.BuildConfig + +class OpenIapStoreFactory : OpenIapProviderFactory { + override val storeId = "amazon" + override val coreVersion: String get() = BuildConfig.OPENIAP_CORE_VERSION + override val clientProtocolVersion = io.github.hyochan.openiap.core.BuildConfig.CLIENT_PROTOCOL_VERSION + override val capabilities = setOf("pendingPurchases") + override fun create(context: Context): OpenIapProtocol = OpenIapModule(context) +} diff --git a/packages/google/openiap/src/horizon/AndroidManifest.xml b/packages/google/openiap/src/horizon/AndroidManifest.xml new file mode 100644 index 000000000..3ee954071 --- /dev/null +++ b/packages/google/openiap/src/horizon/AndroidManifest.xml @@ -0,0 +1,2 @@ + + 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 6acf617b4..bd4ef09b7 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 @@ -843,7 +843,7 @@ class OpenIapModule( * Get available items by product type (Play-compatible API for Horizon) * Used by react-native-iap when type filter is specified */ - suspend fun getAvailableItems(type: ProductQueryType): List = withContext(Dispatchers.IO) { + override suspend fun getAvailableItems(type: ProductQueryType): List = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared val billingType = if (type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP queryPurchasesHorizon(client, activeOperations, billingType) @@ -947,7 +947,12 @@ class OpenIapModule( override val requestPurchase: MutationRequestPurchaseHandler = { props -> val purchases = withContext(Dispatchers.IO) { - val androidArgs = props.toAndroidPurchaseArgs() + val androidArgs = try { + props.toAndroidPurchaseArgs() + } catch (error: IllegalArgumentException) { + emitPurchaseError(OpenIapError.DeveloperError(error.message)) + return@withContext emptyList() + } OpenIapLog.info("=== REQUEST PURCHASE: ${androidArgs.skus} ===", TAG) val activity = currentActivityRef?.get() ?: fallbackActivity @@ -1028,9 +1033,7 @@ class OpenIapModule( ) if (installError != null) { OpenIapLog.warn("requestPurchase rejected: ${installError.message}", TAG) - if (installError is OpenIapError.ServiceDisconnected) { - emitPurchaseError(installError) - } + emitPurchaseError(installError) resumer.resumeWithException(installError) return@suspendCancellableCoroutine } @@ -1124,7 +1127,7 @@ class OpenIapModule( } val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder() - .setOldPurchaseToken(androidArgs.purchaseToken) + .setOldPurchaseToken(requireNotNull(androidArgs.purchaseToken)) // Set replacement mode - this is critical for upgrades updateParamsBuilder.setSubscriptionReplacementMode(5) @@ -1221,16 +1224,11 @@ class OpenIapModule( runCatching { listener.onPurchaseUpdated(purchase) } } } - } else if (matched.isEmpty() && consumePurchaseCallback( - callback, - Result.success(emptyList()), - client, - ) - ) { + } else if (matched.isEmpty()) { val timeoutError = OpenIapError.ServiceTimeout( "Meta Horizon purchase did not complete within the polling window" ).withProductId(androidArgs.skus.singleOrNull()) - emitPurchaseError(timeoutError) + finishPurchaseCallback(client, callback, timeoutError) } } } @@ -1566,7 +1564,7 @@ class OpenIapModule( } } - suspend fun getStorefront(): String = withContext(Dispatchers.IO) { + override suspend fun getStorefront(): String = withContext(Dispatchers.IO) { val client = billingClient ?: emitFailureAndThrow( OpenIapError.NotPrepared, ::emitPurchaseError, @@ -1798,20 +1796,13 @@ class OpenIapModule( OpenIapLog.info("=== END onPurchasesUpdated ===", TAG) } catch (error: Exception) { OpenIapLog.error("Exception in onPurchasesUpdated", error, TAG) - if ( - pendingRequest?.launchStartedAtMillis != null && - consumePurchaseCallback( - pendingRequest.callback, - Result.success(emptyList()), - expectedClient, - ) - ) { + if (pendingRequest?.launchStartedAtMillis != null) { val purchaseError = (error as? OpenIapError) ?: OpenIapError.PurchaseFailed( error.message ?: "Failed to process the Horizon purchase update" ) purchaseError.withProductId(pendingRequest.requestedSkus.singleOrNull()) - emitPurchaseError(purchaseError) + finishPurchaseCallback(expectedClient, pendingRequest.callback, purchaseError) } } } diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapStoreFactory.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapStoreFactory.kt new file mode 100644 index 000000000..dd4683a8b --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapStoreFactory.kt @@ -0,0 +1,12 @@ +package dev.hyo.openiap + +import android.content.Context +import io.github.hyochan.openiap.BuildConfig + +class OpenIapStoreFactory : OpenIapProviderFactory { + override val storeId = "horizon" + override val coreVersion: String get() = BuildConfig.OPENIAP_CORE_VERSION + override val clientProtocolVersion = io.github.hyochan.openiap.core.BuildConfig.CLIENT_PROTOCOL_VERSION + override val capabilities = setOf("pendingPurchases") + override fun create(context: Context): OpenIapProtocol = OpenIapModule(context) +} diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt index 2f0e15acc..94642799e 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -147,6 +147,7 @@ internal object HorizonBillingConverters { quantity = quantity, signatureAndroid = signature, store = IapStore.Horizon, + storeId = "horizon", transactionDate = purchaseTime.toDouble(), transactionId = orderId?.takeIf { it.isNotBlank() } ?: token, // Amazon-flavor-only fields; Horizon purchases never carry them. diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt index d76f179d5..a7b27e1cf 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt @@ -1,5 +1,7 @@ package dev.hyo.openiap +import android.content.Context + /** * OpenIAP specific exceptions */ @@ -10,14 +12,14 @@ sealed class OpenIapError : Exception() { var subResponseCode: SubResponseCodeAndroid? = null private set - internal fun withSubResponseCode(value: SubResponseCodeAndroid?): OpenIapError = apply { + fun withSubResponseCode(value: SubResponseCodeAndroid?): OpenIapError = apply { subResponseCode = value } - internal var requestProductId: String? = null + var requestProductId: String? = null private set - internal fun withProductId(value: String?): OpenIapError = apply { + fun withProductId(value: String?): OpenIapError = apply { requestProductId = value } @@ -83,7 +85,7 @@ sealed class OpenIapError : Exception() { } /** Per-request deferred purchase carrying request diagnostics. */ - internal data class DeferredPurchase(override val debugMessage: String? = null) : OpenIapError() { + data class DeferredPurchase(override val debugMessage: String? = null) : OpenIapError() { override val code: String = PurchaseDeferred.CODE override val message: String = PurchaseDeferred.MESSAGE } @@ -137,7 +139,7 @@ sealed class OpenIapError : Exception() { } /** Per-request Play Billing network failure carrying callback diagnostics. */ - internal data class NetworkFailure(override val debugMessage: String? = null) : OpenIapError() { + data class NetworkFailure(override val debugMessage: String? = null) : OpenIapError() { override val code: String = NetworkError.CODE override val message: String = NetworkError.MESSAGE } @@ -178,19 +180,34 @@ sealed class OpenIapError : Exception() { object InitConnection : OpenIapError() { val CODE = ErrorCode.InitConnection.rawValue override val code = CODE - override val message = buildMessage() + override val message = MESSAGE const val MESSAGE = "Failed to initialize billing connection" - /** Name the store this binary links; the same build is correct on its own device. */ - private fun buildMessage(): String { - val store = io.github.hyochan.openiap.BuildConfig.OPENIAP_STORE - return if (store.lowercase() == "play") { - MESSAGE - } else { - "$MESSAGE. This build targets the $store store, not Google Play." + /** Names the linked store; Play and unknown stores keep the plain message. */ + fun forStore(storeId: String?): OpenIapError = + if (storeId.isNullOrBlank() || storeId == "play") this else StoreConnectionFailure(storeId) + + /** Names the linked store at failure time; an unreadable provider keeps the plain message. */ + fun forProvider(context: Context): OpenIapError = + try { + forStore(OpenIapProvider.factory(context).storeId) + } catch (_: Exception) { + this } - } + + fun messageFor(storeId: String): String = + if (storeId == "play") MESSAGE + else "$MESSAGE. This build targets the $storeId store, not Google Play." + } + + class ProviderConfiguration(override val message: String) : OpenIapError() { + override val code = ErrorCode.DeveloperError.rawValue + } + + class StoreConnectionFailure(storeId: String) : OpenIapError() { + override val code = InitConnection.CODE + override val message = InitConnection.messageFor(storeId) } open class QueryProduct( @@ -261,7 +278,7 @@ sealed class OpenIapError : Exception() { } /** Per-request offer mismatch carrying purchase diagnostics. */ - internal class SkuOfferMismatchFailure : OpenIapError() { + class SkuOfferMismatchFailure : OpenIapError() { override val code: String = SkuOfferMismatch.CODE override val message: String = SkuOfferMismatch.MESSAGE } @@ -341,7 +358,7 @@ sealed class OpenIapError : Exception() { override val message: String get() = messageOverride ?: MESSAGE - internal fun withMessage(value: String): DeveloperError = apply { + fun withMessage(value: String): DeveloperError = apply { messageOverride = value } 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 98a45f0ff..572c9201d 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 @@ -34,6 +34,12 @@ interface OpenIapProtocol { val mutationHandlers: MutationHandlers val subscriptionHandlers: SubscriptionHandlers + suspend fun getStorefront(): String = queryHandlers.getStorefront?.invoke() + ?: throw OpenIapError.FeatureNotSupported("Storefront query is unavailable") + + suspend fun getAvailableItems(type: ProductQueryType): List = + throw OpenIapError.FeatureNotSupported("Use getAvailablePurchases without a product-type filter") + fun setActivity(activity: Activity?) fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) @@ -127,15 +133,9 @@ interface OpenIapProtocol { params: InAppMessageParamsAndroid? = null ): InAppMessageResultAndroid - // Offer Code Redemption (Google Play) /** - * Open the Google Play offer/promo code redemption page so the user can enter a code. - * A listener can receive the redeemed purchase while the app has an active - * billing connection; reconcile available purchases when the app resumes. - * Does not require the billing client to be initialized. - * - * @param activity Current activity context - * @return true when the redemption flow was launched + * Launch the provider's offer-code redemption flow, returning whether it opened. + * Keep purchase listeners active and reconcile owned purchases when the app resumes. */ suspend fun openRedeemOfferCode(activity: Activity): Boolean } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProvider.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProvider.kt new file mode 100644 index 000000000..e9a6b5989 --- /dev/null +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProvider.kt @@ -0,0 +1,108 @@ +package dev.hyo.openiap + +import android.content.Context +import android.content.pm.PackageManager +import io.github.hyochan.openiap.core.BuildConfig +import java.lang.reflect.InvocationTargetException + +/** Implement in any package and register the class in application manifest metadata. */ +interface OpenIapProviderFactory { + val storeId: String + val coreVersion: String + val clientProtocolVersion: String + val capabilities: Set get() = emptySet() + val descriptor: StoreProviderDescriptor get() = StoreProviderDescriptor( + capabilities = capabilities.sorted(), clientProtocolVersion = clientProtocolVersion, + coreVersion = coreVersion, platform = IapPlatform.Android, storeId = storeId, + ) + fun create(context: Context): OpenIapProtocol +} + +/** The single discovery path used by official and external Android providers. */ +object OpenIapProvider { + const val METADATA_KEY = "dev.hyo.openiap.PROVIDER" + val coreVersion: String get() = BuildConfig.OPENIAP_CORE_VERSION + + fun factory(context: Context): OpenIapProviderFactory { + try { + val info = context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) + val className = info.metaData?.getString(METADATA_KEY) + ?: throw OpenIapError.ProviderConfiguration("No Android store provider registered. Select openiapStore and link its provider artifact.") + val type = Class.forName(className, true, context.classLoader) + if (!OpenIapProviderFactory::class.java.isAssignableFrom(type)) { + throw OpenIapError.ProviderConfiguration("$className must implement OpenIapProviderFactory.") + } + val factory = type.getConstructor().newInstance() as OpenIapProviderFactory + validate(factory.descriptor) + return factory + } catch (error: OpenIapError) { + throw error + } catch (error: ReflectiveOperationException) { + throw configurationError(error) + } catch (error: LinkageError) { + throw configurationError(error) + } catch (error: PackageManager.NameNotFoundException) { + throw configurationError(error) + } catch (error: RuntimeException) { + throw configurationError(error) + } + } + + fun create(context: Context): OpenIapProtocol = create(context, factory(context)) + + fun create(context: Context, factory: OpenIapProviderFactory): OpenIapProtocol { + validate(factory.descriptor) + try { + return factory.create(context) + } catch (error: OpenIapError) { + throw error + } catch (error: LinkageError) { + throw configurationError(error) + } + } + + fun validate(storeId: String, providerCoreVersion: String, runtimeCoreVersion: String = coreVersion) { + validateVersion(storeId, providerCoreVersion, runtimeCoreVersion, clientProtocol = false) + } + + // One version check serves both contracts; the flag only selects the wording. + private fun validateVersion(storeId: String, providerVersion: String, runtimeVersion: String, clientProtocol: Boolean) { + if (!storeId.matches(Regex("[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*")) || storeId in setOf("auto", "none", "apple", "google", "unknown")) { + throw OpenIapError.ProviderConfiguration("Invalid Android provider storeId '$storeId'. Use a lowercase stable store id.") + } + val version = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z.-]+))?(?:\\+[0-9A-Za-z.-]+)?") + val field = if (clientProtocol) "Client Protocol version" else "core version" + fun parse(value: String): MatchResult = version.matchEntire(value) + ?: throw OpenIapError.ProviderConfiguration("Invalid $field '$value' for provider '$storeId'. Use a complete semantic version.") + val built = parse(providerVersion) + val runtime = parse(runtimeVersion) + val required = (1..3).map { built.groupValues[it].toBigInteger() } + val available = (1..3).map { runtime.groupValues[it].toBigInteger() } + val order = required.zip(available).firstOrNull { (a, b) -> a != b }?.let { (a, b) -> a.compareTo(b) } ?: 0 + val prerelease = built.groupValues[4].isNotEmpty() || runtime.groupValues[4].isNotEmpty() + if (required[0] != available[0] || order > 0 || (prerelease && providerVersion != runtimeVersion)) { + if (clientProtocol) { + throw OpenIapError.ProviderConfiguration("Provider '$storeId' requires Client Protocol $providerVersion; this app links $runtimeVersion. Update the provider or the OpenIAP runtime to a matching protocol.") + } + throw OpenIapError.ProviderConfiguration("Provider '$storeId' requires openiap-core $providerVersion; this app links $runtimeVersion. Use a compatible provider or core version.") + } + } + + fun validate(descriptor: StoreProviderDescriptor) { + if (descriptor.platform != IapPlatform.Android) { + throw OpenIapError.ProviderConfiguration("Android provider must use the android platform binding.") + } + validate(descriptor.storeId, descriptor.coreVersion) + validateVersion(descriptor.storeId, descriptor.clientProtocolVersion, BuildConfig.CLIENT_PROTOCOL_VERSION, clientProtocol = true) + if (descriptor.clientProtocolVersion.startsWith("0.") && + descriptor.clientProtocolVersion.split('.')[1] != BuildConfig.CLIENT_PROTOCOL_VERSION.split('.')[1]) { + throw OpenIapError.ProviderConfiguration("Provider '${descriptor.storeId}' must implement Client Protocol ${BuildConfig.CLIENT_PROTOCOL_VERSION}.") + } + } + + private fun configurationError(error: Throwable): OpenIapError.ProviderConfiguration { + val cause = if (error is InvocationTargetException) error.targetException else error + return OpenIapError.ProviderConfiguration("Android store provider could not load (${cause.javaClass.simpleName}). Check its factory, public no-argument constructor and openiap-core version.") + .apply { initCause(cause) } + } +} 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 bc5380f51..0c764b41a 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 @@ -7,6 +7,27 @@ @file:Suppress("UNCHECKED_CAST") package dev.hyo.openiap +public object StoreIds { + const val Apple = "apple" + const val Play = "play" + const val Horizon = "horizon" + const val Amazon = "amazon" +} + +private fun resolveStoreId(store: IapStore, value: Any?): String { + val official = when (store) { + IapStore.Apple -> "apple" + IapStore.Google -> "play" + IapStore.Horizon -> "horizon" + IapStore.Amazon -> "amazon" + IapStore.Unknown -> null + } + require(value == null || value is String) { "storeId must be a string" } + val id = value as String? ?: official + require(id != null && if (official != null) id == official else id.matches(Regex("[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*")) && id !in setOf("auto", "none", "unknown", "apple", "play", "google", "horizon", "amazon")) { "Invalid store identity" } + return id +} + // MARK: - Enums /** @@ -828,6 +849,9 @@ public enum class IapPlatform(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Frozen legacy store discriminator. Use storeId for extensible store identity. + */ public enum class IapStore(val rawValue: String) { Unknown("unknown"), Apple("apple"), @@ -1490,6 +1514,9 @@ public interface PurchaseCommon { val currentPlanId: String? val id: String val ids: List? + /** + * Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. + */ val isAutoRenewing: Boolean val productId: String val purchaseState: PurchaseState @@ -1502,6 +1529,10 @@ public interface PurchaseCommon { * Store where purchase was made */ val store: IapStore + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + val storeId: String /** * Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -1521,6 +1552,9 @@ public interface VerifyPurchaseResultCommon { // MARK: - Objects public data class ActiveSubscription( + /** + * Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. + */ val autoRenewingAndroid: Boolean? = null, val basePlanIdAndroid: String? = null, /** @@ -3035,6 +3069,9 @@ public data class ProductSubscriptionIOS( } public data class PurchaseAndroid( + /** + * Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. + */ val autoRenewingAndroid: Boolean? = null, override val currentPlanId: String? = null, val dataAndroid: String? = null, @@ -3042,6 +3079,9 @@ public data class PurchaseAndroid( override val id: String, override val ids: List? = null, val isAcknowledgedAndroid: Boolean? = null, + /** + * Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. + */ override val isAutoRenewing: Boolean, /** * Whether the subscription is suspended (Android) @@ -3070,6 +3110,10 @@ public data class PurchaseAndroid( * Store where purchase was made */ override val store: IapStore, + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + override val storeId: String, /** * Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -3090,6 +3134,7 @@ public data class PurchaseAndroid( companion object { fun fromJson(json: Map): PurchaseAndroid { + val store = IapStore.fromJson(json["store"] as? String ?: "") return PurchaseAndroid( autoRenewingAndroid = json["autoRenewingAndroid"] as? Boolean, currentPlanId = json["currentPlanId"] as? String, @@ -3109,7 +3154,8 @@ public data class PurchaseAndroid( 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 = store, + storeId = resolveStoreId(store, json["storeId"]), transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String, userIdAmazon = json["userIdAmazon"] as? String, @@ -3139,6 +3185,7 @@ public data class PurchaseAndroid( "quantity" to quantity, "signatureAndroid" to signatureAndroid, "store" to store.toJson(), + "storeId" to storeId, "transactionDate" to transactionDate, "transactionId" to transactionId, "userIdAmazon" to userIdAmazon, @@ -3255,6 +3302,9 @@ public data class PurchaseIOS( val expirationDateIOS: Double? = null, override val id: String, override val ids: List? = null, + /** + * Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. + */ override val isAutoRenewing: Boolean, val isUpgradedIOS: Boolean? = null, val offerIOS: PurchaseOfferIOS? = null, @@ -3292,6 +3342,10 @@ public data class PurchaseIOS( */ override val store: IapStore, val storefrontCountryCodeIOS: String? = null, + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + override val storeId: String, val subscriptionGroupIdIOS: String? = null, /** * Unix timestamp in milliseconds since January 1, 1970 UTC. @@ -3304,6 +3358,7 @@ public data class PurchaseIOS( companion object { fun fromJson(json: Map): PurchaseIOS { + val store = IapStore.fromJson(json["store"] as? String ?: "") return PurchaseIOS( advancedCommerceInfoIOS = (json["advancedCommerceInfoIOS"] as? Map)?.let { AdvancedCommerceInfoIOS.fromJson(it) }, appAccountToken = json["appAccountToken"] as? String, @@ -3340,8 +3395,9 @@ public data class PurchaseIOS( revocationDateIOS = (json["revocationDateIOS"] as? Number)?.toDouble(), revocationReasonIOS = json["revocationReasonIOS"] as? String, revocationTypeIOS = json["revocationTypeIOS"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = store, storefrontCountryCodeIOS = json["storefrontCountryCodeIOS"] as? String, + storeId = resolveStoreId(store, json["storeId"]), subscriptionGroupIdIOS = json["subscriptionGroupIdIOS"] as? String, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String ?: "", @@ -3390,6 +3446,7 @@ public data class PurchaseIOS( "revocationTypeIOS" to revocationTypeIOS, "store" to store.toJson(), "storefrontCountryCodeIOS" to storefrontCountryCodeIOS, + "storeId" to storeId, "subscriptionGroupIdIOS" to subscriptionGroupIdIOS, "transactionDate" to transactionDate, "transactionId" to transactionId, @@ -3631,7 +3688,7 @@ public data class RequestPurchaseResultPurchase(val value: Purchase?) : RequestP public data class RequestPurchaseResultPurchases(val value: List?) : RequestPurchaseResult -public data class RequestVerifyPurchaseWithIapkitResult( +public class RequestVerifyPurchaseWithIapkitResult private constructor( /** * True when the purchase is valid and actionable. * Only entitled, pending-acknowledgment, or ready-to-consume return true. @@ -3643,7 +3700,8 @@ public data class RequestVerifyPurchaseWithIapkitResult( * The current state of the purchase. */ val state: IapkitPurchaseState, - val store: IapStore + val store: IapStore, + explicitStoreId: String?, ) { /** @@ -3675,6 +3733,24 @@ public data class RequestVerifyPurchaseWithIapkitResult( var environment: String? = null private set + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + var storeId: String = explicitStoreId?.let { resolveStoreId(store, it) } ?: when (store) { IapStore.Apple -> StoreIds.Apple; IapStore.Google -> StoreIds.Play; IapStore.Horizon -> StoreIds.Horizon; IapStore.Amazon -> StoreIds.Amazon; IapStore.Unknown -> throw IllegalArgumentException("RequestVerifyPurchaseWithIapkitResult with store Unknown requires an explicit valid storeId") } + private set + + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + ) : this( + isValid = isValid, + state = state, + store = store, + explicitStoreId = null, + ) { + } + constructor( isValid: Boolean, state: IapkitPurchaseState, @@ -3685,6 +3761,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( isValid = isValid, state = state, store = store, + explicitStoreId = null, ) { this.clientPayload = clientPayload this.productId = productId @@ -3701,21 +3778,80 @@ public data class RequestVerifyPurchaseWithIapkitResult( isValid = isValid, state = state, store = store, + explicitStoreId = null, + ) { + this.clientPayload = clientPayload + this.productId = productId + this.environment = environment + } + + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + clientPayload: IapkitProductClientPayload? = null, + productId: String? = null, + environment: String? = null, + storeId: String, + ) : this( + isValid = isValid, + state = state, + store = store, + explicitStoreId = storeId, ) { this.clientPayload = clientPayload this.productId = productId this.environment = environment } + operator fun component1(): Boolean = isValid + operator fun component2(): IapkitPurchaseState = state + operator fun component3(): IapStore = store + + fun copy( + isValid: Boolean = this.isValid, + state: IapkitPurchaseState = this.state, + store: IapStore = this.store, + storeId: String? = null, + ): RequestVerifyPurchaseWithIapkitResult { + val resolvedStoreId = storeId?.let { resolveStoreId(store, it) } ?: if (store == this.store) this.storeId else when (store) { IapStore.Apple -> StoreIds.Apple; IapStore.Google -> StoreIds.Play; IapStore.Horizon -> StoreIds.Horizon; IapStore.Amazon -> StoreIds.Amazon; IapStore.Unknown -> throw IllegalArgumentException("RequestVerifyPurchaseWithIapkitResult with store Unknown requires an explicit valid storeId") } + return RequestVerifyPurchaseWithIapkitResult( + isValid = isValid, + state = state, + store = store, + clientPayload = this.clientPayload, + productId = this.productId, + environment = this.environment, + storeId = resolvedStoreId, + ) + } + + override fun equals(other: Any?): Boolean = other is RequestVerifyPurchaseWithIapkitResult && + isValid == other.isValid && state == other.state && store == other.store && clientPayload == other.clientPayload && productId == other.productId && environment == other.environment && storeId == other.storeId + override fun hashCode(): Int { + var result = 1 + result = 31 * result + isValid.hashCode() + result = 31 * result + state.hashCode() + result = 31 * result + store.hashCode() + result = 31 * result + (clientPayload?.hashCode() ?: 0) + result = 31 * result + (productId?.hashCode() ?: 0) + result = 31 * result + (environment?.hashCode() ?: 0) + result = 31 * result + storeId.hashCode() + return result + } + override fun toString(): String = "RequestVerifyPurchaseWithIapkitResult(isValid=$isValid, state=$state, store=$store, clientPayload=$clientPayload, productId=$productId, environment=$environment, storeId=$storeId)" + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { + val store = IapStore.fromJson(json["store"] as? String ?: "") return RequestVerifyPurchaseWithIapkitResult( isValid = json["isValid"] as? Boolean ?: false, state = (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } ?: IapkitPurchaseState.Unknown, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = store, clientPayload = (json["clientPayload"] as? Map)?.let { runCatching { IapkitProductClientPayload.fromJson(it) }.getOrNull() }, productId = json["productId"] as? String, environment = json["environment"] as? String, + storeId = resolveStoreId(store, json["storeId"]), ) } } @@ -3723,6 +3859,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun toJson(): Map = mapOf( "__typename" to "RequestVerifyPurchaseWithIapkitResult", "store" to store.toJson(), + "storeId" to storeId, "environment" to environment, "isValid" to isValid, "state" to state.toJson(), @@ -3731,6 +3868,41 @@ public data class RequestVerifyPurchaseWithIapkitResult( ) } +/** + * Store-provider contract shared by the Apple and Android native bindings. + * coreVersion names the native contract build; clientProtocolVersion names the + * Client Protocol build. Capabilities use the conformance provider profile ids. + */ +public data class StoreProviderDescriptor( + val capabilities: List, + val clientProtocolVersion: String, + val coreVersion: String, + val platform: IapPlatform, + val storeId: String +) { + + companion object { + fun fromJson(json: Map): StoreProviderDescriptor { + return StoreProviderDescriptor( + capabilities = (json["capabilities"] as? List<*>)?.mapNotNull { it as? String } ?: throw IllegalArgumentException("Missing capabilities for StoreProviderDescriptor"), + clientProtocolVersion = (json["clientProtocolVersion"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank clientProtocolVersion for StoreProviderDescriptor"), + coreVersion = (json["coreVersion"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank coreVersion for StoreProviderDescriptor"), + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: throw IllegalArgumentException("Missing required enum value for IapPlatform"), + storeId = (json["storeId"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank storeId for StoreProviderDescriptor"), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "StoreProviderDescriptor", + "capabilities" to capabilities, + "clientProtocolVersion" to clientProtocolVersion, + "coreVersion" to coreVersion, + "platform" to platform.toJson(), + "storeId" to storeId, + ) +} + public data class SubscriptionCommitmentInfoIOS( val displayPrice: String, val period: SubscriptionPeriodValueIOS, @@ -4984,10 +5156,8 @@ public data class RequestPurchaseProps( * Platform-specific purchase request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ public data class RequestPurchasePropsByPlatforms( /** @@ -5188,10 +5358,8 @@ public data class RequestSubscriptionIosProps( * Platform-specific subscription request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ public data class RequestSubscriptionPropsByPlatforms( /** @@ -5927,6 +6095,7 @@ public interface MutationResolver { suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ suspend fun requestPurchase(params: RequestPurchaseProps): RequestPurchaseResult? @@ -6333,6 +6502,7 @@ public data class MutationHandlers( val presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = null, /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ val requestPurchase: MutationRequestPurchaseHandler? = null, diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ActiveStoreOperationRegistry.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ActiveStoreOperationRegistry.kt index b5810555e..d69990208 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ActiveStoreOperationRegistry.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ActiveStoreOperationRegistry.kt @@ -4,13 +4,13 @@ import dev.hyo.openiap.OpenIapError import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.atomic.AtomicReference -internal data class ActiveStoreConnection( +data class ActiveStoreConnection( val client: Client?, val generation: Long, ) /** Owns callbacks registered for the lifetime of one store connection. */ -internal class ActiveStoreListenerOwner( +class ActiveStoreListenerOwner( private val lock: Any, private val current: () -> ActiveStoreConnection, private val source: () -> Client?, @@ -34,7 +34,7 @@ internal class ActiveStoreListenerOwner( } /** Resets state derived from the previous client at a connection transition. */ -internal inline fun transitionStoreConnection( +inline fun transitionStoreConnection( current: Client?, next: Client?, resetDerivedState: () -> Unit, @@ -43,7 +43,7 @@ internal inline fun transitionStoreConnection( return next } -internal class ActiveStoreOperationCompletion internal constructor( +class ActiveStoreOperationCompletion constructor( private val onIsActive: () -> Boolean, private val onSuccess: (T, () -> Unit) -> Unit, private val onFailure: (Throwable, () -> Unit) -> Unit, @@ -57,7 +57,7 @@ internal class ActiveStoreOperationCompletion internal constructor( } /** Tracks callback operations owned by one BillingClient generation. */ -internal class ActiveStoreOperationRegistry( +class ActiveStoreOperationRegistry( private val lock: Any, private val current: () -> ActiveStoreConnection, ) { @@ -134,7 +134,7 @@ internal class ActiveStoreOperationRegistry( .map { owner -> { owner.disconnect(error()) } } } - internal fun size(): Int = synchronized(lock) { owners.size } + fun size(): Int = synchronized(lock) { owners.size } private fun register( client: Client, 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 29c0bafc1..1fd82dd55 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 @@ -21,9 +21,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine /** * Suspend function to wait for a purchase update via listener. - * Shared between Play and Horizon flavors. */ -internal suspend fun onPurchaseUpdated( +suspend fun onPurchaseUpdated( addListener: (OpenIapPurchaseUpdateListener) -> Unit, removeListener: (OpenIapPurchaseUpdateListener) -> Unit ): Purchase = suspendCancellableCoroutine { continuation -> @@ -41,9 +40,8 @@ internal suspend fun onPurchaseUpdated( /** * Suspend function to wait for a purchase error via listener. - * Shared between Play and Horizon flavors. */ -internal suspend fun onPurchaseError( +suspend fun onPurchaseError( addListener: (OpenIapPurchaseErrorListener) -> Unit, removeListener: (OpenIapPurchaseErrorListener) -> Unit ): PurchaseError = suspendCancellableCoroutine { continuation -> @@ -61,9 +59,8 @@ internal suspend fun onPurchaseError( /** * Suspend function to wait for a subscription billing-issue event via listener. - * Shared between Play and Horizon flavors (Horizon never fires). */ -internal suspend fun onSubscriptionBillingIssue( +suspend fun onSubscriptionBillingIssue( addListener: (OpenIapSubscriptionBillingIssueListener) -> Unit, removeListener: (OpenIapSubscriptionBillingIssueListener) -> Unit ): Purchase = suspendCancellableCoroutine { continuation -> @@ -82,7 +79,7 @@ internal suspend fun onSubscriptionBillingIssue( /** * Suspend function to wait for a User Choice Billing selection. */ -internal suspend fun onUserChoiceBilling( +suspend fun onUserChoiceBilling( addListener: (OpenIapUserChoiceBillingListener) -> Unit, removeListener: (OpenIapUserChoiceBillingListener) -> Unit ): UserChoiceBillingDetails = suspendCancellableCoroutine { continuation -> @@ -100,7 +97,7 @@ internal suspend fun onUserChoiceBilling( /** * Suspend function to wait for a Developer Provided Billing selection. */ -internal suspend fun onDeveloperProvidedBilling( +suspend fun onDeveloperProvidedBilling( addListener: (OpenIapDeveloperProvidedBillingListener) -> Unit, removeListener: (OpenIapDeveloperProvidedBillingListener) -> Unit ): DeveloperProvidedBillingDetailsAndroid = suspendCancellableCoroutine { continuation -> @@ -117,9 +114,8 @@ internal suspend fun onDeveloperProvidedBilling( /** * Data class representing parsed Android purchase arguments. - * Shared between Play and Horizon flavors. */ -internal data class AndroidPurchaseArgs( +data class AndroidPurchaseArgs( val skus: List, val isOfferPersonalized: Boolean?, val obfuscatedAccountId: String?, @@ -138,18 +134,18 @@ internal data class AndroidPurchaseArgs( * Applying that object to multiple target products is ambiguous because Play * Billing associates replacement parameters with each ProductDetails entry. */ -internal fun isSubscriptionReplacementTargetCountValid( +fun isSubscriptionReplacementTargetCountValid( targetSkuCount: Int, hasProductLevelReplacementParams: Boolean, ): Boolean = !hasProductLevelReplacementParams || targetSkuCount == 1 -internal fun subscriptionUpdateSourceCount( +fun subscriptionUpdateSourceCount( purchaseToken: String?, originalExternalTransactionId: String?, ): Int = listOf(purchaseToken, originalExternalTransactionId) .count { !it.isNullOrBlank() } -internal fun requireAuthoritativeStorefrontCountry(countryCode: String?): String = +fun requireAuthoritativeStorefrontCountry(countryCode: String?): String = countryCode ?.trim() ?.takeIf { it.isNotEmpty() } @@ -157,7 +153,7 @@ internal fun requireAuthoritativeStorefrontCountry(countryCode: String?): String "Store returned no authoritative storefront country code" ) -internal fun emitFailureAndThrow( +fun emitFailureAndThrow( error: OpenIapError, emitError: (OpenIapError) -> Unit, ): Nothing { @@ -169,7 +165,7 @@ internal fun emitFailureAndThrow( * Complete an asynchronous storefront query exactly once and publish only the * failure that wins the callback race. */ -internal suspend fun awaitAuthoritativeStorefrontCountry( +suspend fun awaitAuthoritativeStorefrontCountry( emitError: (OpenIapError) -> Unit, onRequestFailure: (Exception) -> Unit = {}, request: ( @@ -204,14 +200,14 @@ internal suspend fun awaitAuthoritativeStorefrontCountry( } } -internal fun isConnectionAttemptCurrent( +fun isConnectionAttemptCurrent( currentGeneration: Long, attemptGeneration: Long, ownsAttempt: Boolean, ownsClient: Boolean, ): Boolean = currentGeneration == attemptGeneration && ownsAttempt && ownsClient -internal fun connectionClientToClose( +fun connectionClientToClose( ownsAttempt: Boolean, connected: Boolean, currentClient: T?, @@ -227,7 +223,7 @@ internal fun connectionClientToClose( * Correlate a store purchase update with the request that launched the billing * UI. Purchase updates can also be delivered for out-of-band or older flows. */ -internal fun isPurchaseForPendingRequest( +fun isPurchaseForPendingRequest( transactionDateMillis: Double, productIds: Collection, requestedSkus: Set, @@ -235,7 +231,7 @@ internal fun isPurchaseForPendingRequest( ): Boolean = transactionDateMillis >= launchStartedAtMillis && productIds.any { it in requestedSkus } -internal fun resolveBillingProgramsForConnection( +fun resolveBillingProgramsForConnection( pendingPrograms: Set, configuredProgram: BillingProgramAndroid?, ): Set = buildSet { @@ -247,9 +243,8 @@ internal fun resolveBillingProgramsForConnection( /** * Extension function to convert RequestPurchaseProps to AndroidPurchaseArgs. - * Shared between Play and Horizon flavors. */ -internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { +fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { return when (val payload = request) { is RequestPurchaseProps.Request.Purchase -> { val params = payload.value.google @@ -295,9 +290,8 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { /** * Extension function to convert OpenIapError to PurchaseError. - * Shared between Play and Horizon flavors. */ -internal fun OpenIapError.toPurchaseError(): PurchaseError { +fun OpenIapError.toPurchaseError(): PurchaseError { val code = runCatching { ErrorCode.fromJson(this.code) }.getOrElse { ErrorCode.Unknown } val queryProductError = this as? OpenIapError.QueryProduct val productId = when (this) { diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ContinuationResumeGuard.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ContinuationResumeGuard.kt index d58b6941c..8343cc351 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ContinuationResumeGuard.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ContinuationResumeGuard.kt @@ -11,7 +11,7 @@ import java.util.concurrent.atomic.AtomicBoolean * or from different threads. `isActive` followed by `resume` is not atomic, so * callback bridges should use this guard instead. */ -internal class ContinuationResumeGuard( +class ContinuationResumeGuard( private val continuation: CancellableContinuation, onCancellation: (() -> Unit)? = null ) { @@ -53,6 +53,6 @@ internal class ContinuationResumeGuard( } } -internal fun CancellableContinuation.resumeGuard( +fun CancellableContinuation.resumeGuard( onCancellation: (() -> Unit)? = null ): ContinuationResumeGuard = ContinuationResumeGuard(this, onCancellation) 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 358c4f794..261ef252f 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 @@ -49,7 +49,7 @@ import android.content.Context import dev.hyo.openiap.IapContext import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapLog -import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProvider import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.VerifyPurchaseWithProviderProps import dev.hyo.openiap.VerifyPurchaseWithProviderResult @@ -77,11 +77,7 @@ internal object OpenIapStorePurchaseRequestResolver { } } -/** - * OpenIapStore (Android) - * Convenience store that wraps an [OpenIapProtocol] implementation (Play, Horizon, or Amazon) - * and exposes suspend APIs with observable StateFlows for UI layers to consume. - */ +/** Exposes provider operations and observable StateFlows for UI layers. */ class OpenIapStore(private val module: OpenIapProtocol) { private val manualActivityOwner = Any() private val activityBindings = OwnerScopedValueBinding(module::setActivity) @@ -145,20 +141,15 @@ class OpenIapStore(private val module: OpenIapProtocol) { _status.value = _status.value.copy(lastError = null) pendingRequestProductId = null - // CRITICAL FIX: Refresh available purchases to update UI - // This ensures the purchase list reflects the new purchase immediately storeScope.launch { try { OpenIapLog.info("Purchase update received, refreshing available purchases", "OpenIapStore") - // Wait a bit for the purchase to be fully processed by Horizon + // Horizon ownership can lag behind the purchase callback. kotlinx.coroutines.delay(500) - // Ensure connection is ready if (!isConnected.value) { OpenIapLog.warn("Not connected, skipping purchase refresh (connection will be restored on next app start)", "OpenIapStore") - // Don't attempt to reconnect here as it may cause issues - // The purchase will be available on next app launch return@launch } @@ -486,8 +477,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { } try { - module.mutationHandlers.requestPurchase?.invoke(props) - ?: throw OpenIapError.FeatureNotSupported() + module.requestPurchase(props) } finally { if (skuForStatus != null) removePurchasing(skuForStatus) } @@ -511,7 +501,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { // Check if already processed - but we can't check isAcknowledgedAndroid on PurchaseInput if (token == null || !processedPurchaseTokens.contains(token)) { try { - module.mutationHandlers.finishTransaction?.invoke(purchaseInput, isConsumable) + module.finishTransaction(purchaseInput, isConsumable) if (token != null) processedPurchaseTokens.add(token) } catch (e: Exception) { setError(e.message) @@ -530,7 +520,9 @@ class OpenIapStore(private val module: OpenIapProtocol) { * @see
    https://openiap.dev/docs/apis/get-active-subscriptions */ suspend fun getActiveSubscriptions(subscriptionIds: List? = null): List = - module.queryHandlers.getActiveSubscriptions?.invoke(subscriptionIds) ?: emptyList() + module.getActiveSubscriptions(subscriptionIds) + + suspend fun restorePurchases() = module.restorePurchases() /** Verify a purchase with the configured provider. */ suspend fun verifyPurchaseWithProvider( @@ -543,14 +535,14 @@ class OpenIapStore(private val module: OpenIapProtocol) { * @see https://openiap.dev/docs/apis/has-active-subscriptions */ suspend fun hasActiveSubscriptions(subscriptionIds: List? = null): Boolean = - module.queryHandlers.hasActiveSubscriptions?.invoke(subscriptionIds) ?: false + module.hasActiveSubscriptions(subscriptionIds) /** * Open the platform's subscription management UI. * * @see https://openiap.dev/docs/apis/deep-link-to-subscriptions */ - suspend fun deepLinkToSubscriptions(options: DeepLinkOptions) = module.mutationHandlers.deepLinkToSubscriptions?.invoke(options) + suspend fun deepLinkToSubscriptions(options: DeepLinkOptions) = module.deepLinkToSubscriptions(options) // ------------------------------------------------------------------------- // Billing Programs (Google Play Billing Library 8.2.0+) @@ -779,10 +771,16 @@ sealed class IapOperationResult { } private val storeAliases = mapOf( - "google" to "play", "gplay" to "play", "googleplay" to "play", - "google-play" to "play", "gms" to "play", - "meta" to "horizon", "quest" to "horizon", - "fire" to "amazon", "fireos" to "amazon", "fire-os" to "amazon", + "google" to "play", + "gplay" to "play", + "googleplay" to "play", + "google-play" to "play", + "gms" to "play", + "meta" to "horizon", + "quest" to "horizon", + "fire" to "amazon", + "fireos" to "amazon", + "fire-os" to "amazon", ) /** @@ -791,7 +789,8 @@ private val storeAliases = mapOf( * compatibility and otherwise unused. */ private fun buildModule(context: Context, store: String?, appId: String?): OpenIapProtocol { - val linked = io.github.hyochan.openiap.BuildConfig.OPENIAP_STORE.lowercase(java.util.Locale.ROOT) + val factory = OpenIapProvider.factory(context) + val linked = factory.storeId val requested = store?.trim()?.lowercase(java.util.Locale.ROOT)?.let { storeAliases[it] ?: it } if (requested != null && requested != linked) { OpenIapLog.warn( @@ -802,6 +801,6 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI if (appId != null) { OpenIapLog.debug("Horizon app id comes from AndroidManifest meta-data; constructor value ignored", "OpenIapStore") } - OpenIapLog.info("BuildConfig.OPENIAP_STORE = $linked", "OpenIapStore") - return OpenIapModule(context) + OpenIapLog.info("Linked Android store = $linked", "OpenIapStore") + return OpenIapProvider.create(context, factory) } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/OfferCodeRedemption.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/OfferCodeRedemption.kt new file mode 100644 index 000000000..1b46d0d55 --- /dev/null +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/OfferCodeRedemption.kt @@ -0,0 +1,16 @@ +package dev.hyo.openiap.utils + +import android.app.Activity +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapProtocol + +@Suppress("DEPRECATION") +suspend fun redeemOfferCode(provider: OpenIapProtocol, activity: Activity?): Boolean { + if (activity != null) { + provider.setActivity(activity) + return provider.openRedeemOfferCode(activity) + } + // Legacy handlers preserve the official stores' no-Activity result. + return provider.mutationHandlers.openRedeemOfferCodeAndroid?.invoke() + ?: throw OpenIapError.MissingCurrentActivity +} diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index 221d412b2..65d3d9f41 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -426,6 +426,8 @@ suspend fun verifyPurchaseWithIapkit( OpenIapLog.warn("Ignoring an IAPKit environment this build cannot read", tag) } + // The constructor derives storeId from store; passing it here would + // duplicate the generated mapping. return RequestVerifyPurchaseWithIapkitResult( clientPayload = clientPayload, environment = environment, diff --git a/packages/google/openiap/src/play/AndroidManifest.xml b/packages/google/openiap/src/play/AndroidManifest.xml new file mode 100644 index 000000000..3ee954071 --- /dev/null +++ b/packages/google/openiap/src/play/AndroidManifest.xml @@ -0,0 +1,2 @@ + + 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 ab4a7facc..8ca717c45 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 @@ -1600,7 +1600,11 @@ class OpenIapModule( override val requestPurchase: MutationRequestPurchaseHandler = { props -> val errorEventGate = PurchaseErrorEventGate(::emitPurchaseError) val purchases = try { withContext(Dispatchers.IO) { - val androidArgs = props.toAndroidPurchaseArgs() + val androidArgs = try { + props.toAndroidPurchaseArgs() + } catch (error: IllegalArgumentException) { + throw OpenIapError.DeveloperError(error.message) + } val activity = currentActivityRef?.get() ?: fallbackActivity if (activity == null) { @@ -1814,7 +1818,7 @@ class OpenIapModule( return } - builder.setOfferToken(androidArgs.offerToken) + builder.setOfferToken(requireNotNull(androidArgs.offerToken)) } paramsList += builder.build() @@ -2087,7 +2091,7 @@ class OpenIapModule( RequestPurchaseResultPurchases(purchases) } - suspend fun getAvailableItems(type: ProductQueryType): List = withContext(Dispatchers.IO) { + override suspend fun getAvailableItems(type: ProductQueryType): List = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared val billingType = if (type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP queryPurchases(client, activeOperations, billingType) @@ -2295,7 +2299,7 @@ class OpenIapModule( } } - suspend fun getStorefront(): String = withContext(Dispatchers.IO) { + override suspend fun getStorefront(): String = withContext(Dispatchers.IO) { val client = billingClient ?: emitFailureAndThrow( OpenIapError.NotPrepared, ::emitPurchaseError, diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapStoreFactory.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapStoreFactory.kt new file mode 100644 index 000000000..3370fb267 --- /dev/null +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapStoreFactory.kt @@ -0,0 +1,12 @@ +package dev.hyo.openiap + +import android.content.Context +import io.github.hyochan.openiap.BuildConfig + +class OpenIapStoreFactory : OpenIapProviderFactory { + override val storeId = "play" + override val coreVersion: String get() = BuildConfig.OPENIAP_CORE_VERSION + override val clientProtocolVersion = io.github.hyochan.openiap.core.BuildConfig.CLIENT_PROTOCOL_VERSION + override val capabilities = setOf("pendingPurchases", "subscriptionBillingIssue", "offerCodeRedemption") + override fun create(context: Context): OpenIapProtocol = OpenIapModule(context) +} diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt index a6749c483..9be350f85 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -336,6 +336,7 @@ internal object BillingConverters { quantity = quantity, signatureAndroid = signature, store = IapStore.Google, + storeId = "play", transactionDate = purchaseTime.toDouble(), transactionId = orderId, // Amazon-flavor-only fields; Google Play purchases never carry them. diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt index 9361ff82d..5eac0dbd9 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt @@ -136,14 +136,29 @@ class OpenIapErrorTest { @Test fun `InitConnection names a non-Play flavor`() { val store = io.github.hyochan.openiap.BuildConfig.OPENIAP_STORE - val message = OpenIapError.InitConnection.message + val message = OpenIapError.InitConnection.forStore(store).message if (store == "play") { assertEquals(OpenIapError.InitConnection.MESSAGE, message) } else { assertTrue(message.contains(store)) assertFalse(message.contains("rebuild")) } - assertEquals(message, OpenIapError.defaultMessage(OpenIapError.InitConnection.CODE)) + assertEquals(OpenIapError.InitConnection.MESSAGE, OpenIapError.defaultMessage(OpenIapError.InitConnection.CODE)) + } + + @Test + fun `InitConnection forStore names every non-Play store`() { + for (store in listOf("horizon", "amazon", "community-fixture")) { + val error = OpenIapError.InitConnection.forStore(store) + assertEquals(OpenIapError.InitConnection.CODE, error.code) + assertTrue(error.message.contains(store)) + assertFalse(error.message.contains("rebuild")) + } + for (store in listOf("play", null, "")) { + val error = OpenIapError.InitConnection.forStore(store) + assertEquals(OpenIapError.InitConnection.CODE, error.code) + assertEquals(OpenIapError.InitConnection.MESSAGE, error.message) + } } @Test diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchasePlatformValidationTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchasePlatformValidationTest.kt new file mode 100644 index 000000000..bf6ebfafc --- /dev/null +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchasePlatformValidationTest.kt @@ -0,0 +1,44 @@ +package dev.hyo.openiap + +import androidx.test.core.app.ApplicationProvider +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +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], instrumentedPackages = ["com.amazon"]) +class PurchasePlatformValidationTest { + @Test + fun `apple-only requests emit one developer error before returning or throwing`() = runTest { + for (type in listOf("in-app", "subs")) { + val module = OpenIapModule(ApplicationProvider.getApplicationContext()) + val errors = mutableListOf() + var updates = 0 + module.addPurchaseErrorListener(OpenIapPurchaseErrorListener { errors += it }) + module.addPurchaseUpdateListener { updates += 1 } + val request = RequestPurchaseProps.fromJson(mapOf( + "type" to type, + (if (type == "subs") "requestSubscription" else "requestPurchase") to + mapOf("apple" to mapOf("sku" to "coins")), + )) + + val outcome = runCatching { module.requestPurchase(request) } + + assertEquals(type, 1, errors.size) + assertTrue(errors.single() is OpenIapError.DeveloperError) + assertEquals(0, updates) + outcome.fold( + onSuccess = { result -> + assertTrue((result as RequestPurchaseResultPurchases).value.isNullOrEmpty()) + }, + onFailure = { error -> assertSame(errors.single(), error) }, + ) + } + } +} diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt index c19fb0c99..8f2f880d7 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt @@ -557,6 +557,43 @@ class PurchaseVerificationValidatorTest { assertEquals(true, bodyMap["includeClientPayload"]) } + @Test + fun `verifyPurchaseWithIapkit derives the store id from the verified store`() = runTest { + val cases = listOf( + Triple( + RequestVerifyPurchaseWithIapkitProps( + google = RequestVerifyPurchaseWithIapkitGoogleProps(purchaseToken = "token-123") + ), + """{"store":"google","isValid":true,"state":"ENTITLED"}""", + "play" + ), + Triple( + RequestVerifyPurchaseWithIapkitProps( + horizon = RequestVerifyPurchaseWithIapkitHorizonProps(sku = "premium.monthly", userId = "123456789") + ), + """{"store":"horizon","isValid":true,"state":"ENTITLED"}""", + "horizon" + ), + Triple( + RequestVerifyPurchaseWithIapkitProps( + amazon = RequestVerifyPurchaseWithIapkitAmazonProps( + userId = "amzn1.account.ABC123", + receiptId = "amzn1.receipt.ABC123456789" + ) + ), + """{"store":"amazon","isValid":true,"state":"ENTITLED"}""", + "amazon" + ) + ) + + for ((props, response, expectedStoreId) in cases) { + val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection(200, response) + } + assertEquals(expectedStoreId, result.storeId) + } + } + @Test fun `verifyPurchaseWithIapkit throws when horizon payload is incomplete`() = runTest { for (horizon in listOf( diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt index b4c6fdde0..d0c74b507 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt @@ -752,6 +752,7 @@ class StandardizedOfferTypesTest { transactionDate = 1700000000000.0, purchaseToken = "current_token", store = IapStore.Google, + storeId = "play", quantity = 1, purchaseState = PurchaseState.Purchased, isAutoRenewing = true, @@ -770,6 +771,7 @@ class StandardizedOfferTypesTest { productId = "basic_plan", transactionDate = 1700000000000.0, store = IapStore.Google, + storeId = "play", quantity = 1, purchaseState = PurchaseState.Purchased, isAutoRenewing = true, @@ -817,6 +819,7 @@ class StandardizedOfferTypesTest { productId = "regular_product", transactionDate = 1700000000000.0, store = IapStore.Google, + storeId = "play", quantity = 1, purchaseState = PurchaseState.Purchased, isAutoRenewing = false @@ -834,6 +837,7 @@ class StandardizedOfferTypesTest { productId = "premium_yearly", transactionDate = 1700000000000.0, store = IapStore.Google, + storeId = "play", quantity = 1, purchaseState = PurchaseState.Purchased, isAutoRenewing = true, diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/store/OpenIapStoreConnectionLifecycleTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/store/OpenIapStoreConnectionLifecycleTest.kt index 29a498c26..5fe729216 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/store/OpenIapStoreConnectionLifecycleTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/store/OpenIapStoreConnectionLifecycleTest.kt @@ -40,6 +40,7 @@ class OpenIapStoreConnectionLifecycleTest { purchaseToken = "purchase-token", quantity = 1, store = IapStore.Google, + storeId = "play", transactionDate = 1.0, ) module.availablePurchases = listOf(purchase) diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonProviderDiscoveryTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonProviderDiscoveryTest.kt new file mode 100644 index 000000000..6102116ba --- /dev/null +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/AmazonProviderDiscoveryTest.kt @@ -0,0 +1,25 @@ +package dev.hyo.openiap + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The amazon manifest must keep registering its factory; OpenIapStore depends on it. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], instrumentedPackages = ["com.amazon"]) +class AmazonProviderDiscoveryTest { + @Test + fun `merged manifest discovers the amazon factory`() { + val context: Context = ApplicationProvider.getApplicationContext() + val factory = OpenIapProvider.factory(context) + + assertEquals("dev.hyo.openiap.OpenIapStoreFactory", factory.javaClass.name) + assertEquals("amazon", factory.storeId) + assertTrue(OpenIapProvider.create(context, factory) is OpenIapModule) + } +} diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/conformance/AmazonStoreConformanceTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/conformance/AmazonStoreConformanceTest.kt index af6bfb9a9..cc0cc27b3 100644 --- a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/conformance/AmazonStoreConformanceTest.kt +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/conformance/AmazonStoreConformanceTest.kt @@ -1,20 +1,33 @@ package dev.hyo.openiap.conformance +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.ErrorCode import dev.hyo.openiap.IapStore import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.PurchaseAndroid -import dev.hyo.openiap.ErrorCode import dev.hyo.openiap.amazonPurchaseError -import dev.hyo.openiap.unsupportedRedeemOfferCode import dev.hyo.openiap.utils.toActiveSubscription -import kotlinx.coroutines.runBlocking +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config /** * Amazon Appstore's binding into the shared conformance suite. * The behavioral expectations live in [StoreConformanceSuite]. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], instrumentedPackages = ["com.amazon"]) class AmazonStoreConformanceTest : StoreConformanceSuite() { + override val provider: OpenIapProtocol = + OpenIapModule(ApplicationProvider.getApplicationContext()) + override val redemptionActivity: Activity = + Robolectric.buildActivity(Activity::class.java).setup().get() override val adapter = object : StoreConformanceAdapter { override val store = IapStore.Amazon @@ -34,9 +47,6 @@ class AmazonStoreConformanceTest : StoreConformanceSuite() { override val unrecognizedError = checkNotNull(amazonPurchaseError(null, "sku")) - override fun unsupportedOperationResult(): Boolean = - runBlocking { unsupportedRedeemOfferCode() } - private fun errorCase( status: String, expected: ErrorCode, diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonProviderDiscoveryTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonProviderDiscoveryTest.kt new file mode 100644 index 000000000..d69f6cdf5 --- /dev/null +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonProviderDiscoveryTest.kt @@ -0,0 +1,25 @@ +package dev.hyo.openiap + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The horizon manifest must keep registering its factory; OpenIapStore depends on it. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class HorizonProviderDiscoveryTest { + @Test + fun `merged manifest discovers the horizon factory`() { + val context: Context = ApplicationProvider.getApplicationContext() + val factory = OpenIapProvider.factory(context) + + assertEquals("dev.hyo.openiap.OpenIapStoreFactory", factory.javaClass.name) + assertEquals("horizon", factory.storeId) + assertTrue(OpenIapProvider.create(context, factory) is OpenIapModule) + } +} diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionGroupMappingHorizonTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionGroupMappingHorizonTest.kt index 6dff88baa..d727f2d33 100644 --- a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionGroupMappingHorizonTest.kt +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/SubscriptionGroupMappingHorizonTest.kt @@ -36,6 +36,7 @@ class SubscriptionGroupMappingHorizonTest { quantity = 1, signatureAndroid = null, store = IapStore.Horizon, + storeId = "horizon", transactionDate = 1_700_000_000_000.0, transactionId = token ) diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/conformance/HorizonStoreConformanceTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/conformance/HorizonStoreConformanceTest.kt index 1efe6a35a..0eae21723 100644 --- a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/conformance/HorizonStoreConformanceTest.kt +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/conformance/HorizonStoreConformanceTest.kt @@ -1,19 +1,32 @@ package dev.hyo.openiap.conformance +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider import dev.hyo.openiap.ActiveSubscription import dev.hyo.openiap.IapStore import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.PurchaseAndroid import dev.hyo.openiap.fromBillingResponseCode import dev.hyo.openiap.utils.HorizonBillingConverters.toActiveSubscription -import dev.hyo.openiap.unsupportedRedeemOfferCode -import kotlinx.coroutines.runBlocking +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config /** * Meta Horizon's binding into the shared conformance suite. * The behavioral expectations live in [StoreConformanceSuite]. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) class HorizonStoreConformanceTest : StoreConformanceSuite() { + override val provider: OpenIapProtocol = + OpenIapModule(ApplicationProvider.getApplicationContext()) + override val redemptionActivity: Activity = + Robolectric.buildActivity(Activity::class.java).setup().get() override val adapter = object : StoreConformanceAdapter { override val store = IapStore.Horizon @@ -30,8 +43,5 @@ class HorizonStoreConformanceTest : StoreConformanceSuite() { override val normativeErrorCases = playBillingErrorCases(OpenIapError::fromBillingResponseCode) override val unrecognizedError = OpenIapError.fromBillingResponseCode(9999) - - override fun unsupportedOperationResult(): Boolean = - runBlocking { unsupportedRedeemOfferCode() } } } diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/PlayProviderDiscoveryTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/PlayProviderDiscoveryTest.kt new file mode 100644 index 000000000..1436c7fd3 --- /dev/null +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/PlayProviderDiscoveryTest.kt @@ -0,0 +1,25 @@ +package dev.hyo.openiap + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The play manifest must keep registering its factory; OpenIapStore depends on it. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class PlayProviderDiscoveryTest { + @Test + fun `merged manifest discovers the play factory`() { + val context: Context = ApplicationProvider.getApplicationContext() + val factory = OpenIapProvider.factory(context) + + assertEquals("dev.hyo.openiap.OpenIapStoreFactory", factory.javaClass.name) + assertEquals("play", factory.storeId) + assertTrue(OpenIapProvider.create(context, factory) is OpenIapModule) + } +} diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionGroupMappingPlayTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionGroupMappingPlayTest.kt index 09e675ac6..3eaf41a11 100644 --- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionGroupMappingPlayTest.kt +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionGroupMappingPlayTest.kt @@ -48,6 +48,7 @@ class SubscriptionGroupMappingPlayTest { quantity = 1, signatureAndroid = null, store = IapStore.Google, + storeId = "play", transactionDate = 1_700_000_000_000.0, transactionId = token ) diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt index 097bf4fbf..2f0f0b524 100644 --- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt @@ -27,7 +27,5 @@ class PlayStoreConformanceTest : StoreConformanceSuite() { override val normativeErrorCases = playBillingErrorCases(OpenIapError::fromBillingResponseCode) override val unrecognizedError = OpenIapError.fromBillingResponseCode(9999) - - override fun unsupportedOperationResult(): Boolean? = null } } diff --git a/packages/google/scripts/verify-community-provider.sh b/packages/google/scripts/verify-community-provider.sh new file mode 100755 index 000000000..643f4a3b3 --- /dev/null +++ b/packages/google/scripts/verify-community-provider.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +google_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +repo_root=$(cd "$google_root/../.." && pwd) +fixture_root="$google_root/compatibility/community-provider" +repository=$(mktemp -d) +trap 'rm -rf "$repository"' EXIT +core_version=$(node -p "require(process.argv[1]).google" "$repo_root/openiap-versions.json") +suite_version=$(node --input-type=module -e "import {SUITE_VERSION} from '$repo_root/packages/conformance/src/spec/suite-version.mjs'; console.log(SUITE_VERSION)") + +# Only local Maven artifacts are produced; no release credentials or remote publishing. +"$google_root/gradlew" -p "$google_root" :openiap-core:publishToMavenLocal \ + :openiap-conformance:publishToMavenLocal -Dmaven.repo.local="$repository" +args=(-PopenIapRepository="$repository" -PopenIapVersion="$core_version" -PconformanceVersion="$suite_version") +"$google_root/gradlew" -p "$fixture_root" :provider:negativeConformance \ + :vendor-sdk:publishVendorPublicationToMavenLocal :provider:publishFixturePublicationToMavenLocal -Dmaven.repo.local="$repository" "${args[@]}" +"$google_root/gradlew" -p "$fixture_root" :host:assembleRelease "${args[@]}" \ + -PopeniapStore=community-fixture -PopeniapProvider=community.fixture:provider:1.0.0 +node --input-type=module - "$repo_root" "$fixture_root" <<'JS' +import {readFileSync} from 'node:fs'; +const [root, fixture] = process.argv.slice(2); +const {validateProviderReport} = await import(`${root}/specs/client/store-registry.mjs`); +const {ANDROID_PROVIDER_PROFILE} = await import(`${root}/packages/conformance/src/spec/android-provider-profile.mjs`); +const report = JSON.parse(readFileSync(`${fixture}/provider/build/reports/openiap/community-fixture.json`, 'utf8')); +if (!validateProviderReport(report, ['pendingPurchases', 'subscriptionBillingIssue', 'offerCodeRedemption'])) throw new Error('Fixture report did not pass'); +const negative = JSON.parse(readFileSync(`${fixture}/provider/build/reports/openiap/missing-capability.json`, 'utf8')); +const failed = negative.results.filter((result) => result.outcome === 'fail'); +if (validateProviderReport(negative, report.capabilities) || failed.length !== 1 || failed[0].id !== ANDROID_PROVIDER_PROFILE.capabilities.subscriptionBillingIssue) throw new Error('Missing capability did not fail its required behavior'); +if (report.store !== 'unknown' || report.storeId !== 'community-fixture') throw new Error('Fixture identity was lost'); +const mapping = readFileSync(`${fixture}/host/build/outputs/mapping/release/mapping.txt`, 'utf8'); +if (!mapping.includes('community.fixture.FixtureFactory -> community.fixture.FixtureFactory:')) throw new Error('R8 did not preserve the factory'); +if (['com.android.billingclient.', 'com.meta.horizon.billingclient.', 'com.amazon.device.iap.'].some((sdk) => mapping.includes(sdk))) throw new Error('Fixture linked an official billing SDK'); +console.log(`Provider report passed ${report.results.length} behaviors on suite ${report.suiteVersion}; R8 kept its factory without official billing SDKs.`); +JS +if "$google_root/gradlew" -p "$fixture_root" :host:processReleaseMainManifest "${args[@]}" \ + -PopeniapStore=community-fixture -PopeniapProvider=community.fixture:provider:1.0.0 \ + -PduplicateProvider=true > "$repository/conflict.log" 2>&1; then + echo 'Two providers unexpectedly passed manifest merging.' >&2 + exit 1 +fi +if ! grep -q 'meta-data#dev.hyo.openiap.PROVIDER@value' "$repository/conflict.log"; then + cat "$repository/conflict.log" >&2 + exit 1 +fi +printf 'Duplicate providers failed manifest merging as required.\n' + +plugin_fixture="$google_root/compatibility/store-plugin" +selection=(-PfixtureCommunityRepository="$repository" -PopeniapStore=community-fixture -PopeniapProvider=community.fixture:provider:1.0.0) +"$google_root/gradlew" -p "$plugin_fixture" :app:printDebugStores -PfixtureStrategy=provider "${selection[@]}" > "$repository/plugin.log" 2>&1 +grep -q '^FIXTURE app:debugRuntimeClasspath=openiap-core,provider$' "$repository/plugin.log" +"$google_root/gradlew" -p "$plugin_fixture" :flavored:printProviderStores "${selection[@]}" > "$repository/plugin-flavor.log" 2>&1 +grep -q '^FIXTURE flavored:providerDebugRuntimeClasspath=openiap-core,provider$' "$repository/plugin-flavor.log" +if "$google_root/gradlew" -p "$plugin_fixture" :flavored:printProviderStores \ + -PfixtureCommunityRepository="$repository" > "$repository/plugin-missing.log" 2>&1; then + echo 'A provider flavor unexpectedly resolved without coordinates.' >&2 + exit 1 +fi +grep -q 'provider platform flavor requires openiapStore and openiapProvider' "$repository/plugin-missing.log" +printf 'The Gradle plugin resolved core plus provider for plain and flavored consumers; missing coordinates failed.\n' diff --git a/packages/google/scripts/verify-kotlin-2.1-consumer.sh b/packages/google/scripts/verify-kotlin-2.1-consumer.sh index 6a8cc1c51..96662a153 100755 --- a/packages/google/scripts/verify-kotlin-2.1-consumer.sh +++ b/packages/google/scripts/verify-kotlin-2.1-consumer.sh @@ -19,6 +19,8 @@ openiap_version=$(node -e \ local_repository="$consumer_temp/repository" cd "$google_root" +./gradlew :openiap-core:publishMavenPublicationToMavenLocal \ + -Dmaven.repo.local="$local_repository" --no-daemon for target in \ "play:openiap-google" \ "horizon:openiap-google-horizon" \ diff --git a/packages/google/scripts/verify-release-consumer.sh b/packages/google/scripts/verify-release-consumer.sh index 4f688f6f1..5351c0f6d 100755 --- a/packages/google/scripts/verify-release-consumer.sh +++ b/packages/google/scripts/verify-release-consumer.sh @@ -29,6 +29,8 @@ stores=( ) cd "$google_root" +./gradlew :openiap-core:publishMavenPublicationToMavenLocal \ + -Dmaven.repo.local="$local_repository" --no-daemon for entry in "${stores[@]}"; do ./gradlew :openiap:publishMavenPublicationToMavenLocal \ -POPENIAP_PUBLISH_VARIANT="${entry%% *}" \ diff --git a/packages/google/scripts/verify-store-plugin.sh b/packages/google/scripts/verify-store-plugin.sh index 5334b4c34..c175a09cf 100755 --- a/packages/google/scripts/verify-store-plugin.sh +++ b/packages/google/scripts/verify-store-plugin.sh @@ -8,6 +8,8 @@ google_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) fixture="$google_root/compatibility/store-plugin" # The same KMP modules on AGP 9, whose library plugin selects dependencies differently. fixture_agp9="$google_root/compatibility/store-plugin-agp9" +# A mixed-flavor app: official platform flavors plus the community provider. +fixture_mixed="$google_root/compatibility/store-plugin-mixed" case_fixture="$fixture" gradlew="$google_root/gradlew" # The fixture resolves published artifacts, so a network blip must not fail a case. @@ -21,7 +23,6 @@ fi # The real SDK with a fake adb, so each case declares the device it reports. fake_sdk=$(mktemp -d) -trap 'rm -rf "$fake_sdk"' EXIT for entry in "$real_sdk"/*; do [[ "$(basename "$entry")" == platform-tools ]] || ln -s "$entry" "$fake_sdk/" done @@ -29,6 +30,27 @@ mkdir -p "$fake_sdk/platform-tools" cp "$google_root/compatibility/store-resolver/fake-adb" "$fake_sdk/platform-tools/adb" chmod +x "$fake_sdk/platform-tools/adb" +# A provider the mixed cases resolve: the swapped openiap-core and a community +# provider, as bare POMs. The print tasks list module names, never artifacts. +fake_repo=$(mktemp -d) +trap 'rm -rf "$fake_sdk" "$fake_repo"' EXIT +fake_module() { + local group="$1" artifact="$2" version="$3" + local dir="$fake_repo/$(printf '%s' "$group" | tr '.' '/')/$artifact/$version" + mkdir -p "$dir" + cat > "$dir/$artifact-$version.pom" < + 4.0.0 + $group + $artifact + $version + jar + +EOF +} +fake_module io.github.hyochan.openiap openiap-core 3.6.1 +fake_module dev.example provider 1.0.0 + passed=0 failed=0 @@ -103,6 +125,7 @@ unset FAKE_ADB_DEVICES run "own platform flavors are left alone" "flavored:horizonDebugRuntimeClasspath=kmp-iap-android-horizon,openiap-google-horizon" :flavored:printFlavorStores -PopeniapStore=amazon # One plain dependency there still links each flavor's own store. run "a plain dependency follows each flavor" "flavored:horizonDebugRuntimeClasspath=kmp-iap-android-horizon,openiap-google-horizon" :flavored:printFlavorStores -PfixturePlainDependency=true +run "a pin still leaves a plain flavor alone" "flavored:horizonDebugRuntimeClasspath=kmp-iap-android-horizon,openiap-google-horizon" :flavored:printFlavorStores -PfixturePlainDependency=true -PopeniapStore=amazon run "and so do that flavor's unit tests" "flavored:horizonDebugUnitTestRuntimeClasspath=kmp-iap-android-horizon,openiap-google-horizon" :flavored:printFlavorUnitTestStores -PfixturePlainDependency=true # So is a platform the app requests itself, which the plugin must not replace. @@ -115,6 +138,19 @@ run "a store artifact that disagrees fails" "fail:openiap-google-horizon is decl run "and one that agrees is kept" "mixed:debugRuntimeClasspath=openiap-google-horizon" :mixed:printDebugStores -PopeniapStore=horizon # The swap needs a version of its own; one borrowed from another edge is not it. run "an unversioned openiap-google says so" "fail:openiap-google is declared without a version" :unversioned:printDebugStores -PopeniapStore=horizon +# A provider build links openiap-core, not a store artifact, so the error names it. +run "and in a provider build it names core" "fail:declare a version so the plugin can link openiap-core" :unversioned:printDebugStores -PfixtureCommunityRepository="$fake_repo" -PopeniapStore=fixture-provider -PopeniapProvider=dev.example:provider:1.0.0 + +# A mixed-flavor app: the two properties select the provider flavor only, so an +# official flavor's task never conflicts with the community pin. +case_fixture="$fixture_mixed" +mixed=(-PfixtureCommunityRepository="$fake_repo" -PopeniapStore=fixture-provider -PopeniapProvider=dev.example:provider:1.0.0) +run "a provider pin beside a play task" "app:playDebugRuntimeClasspath=openiap-google" :app:printPlayStores "${mixed[@]}" +run "and beside a horizon task" "app:horizonDebugRuntimeClasspath=openiap-google-horizon" :app:printHorizonStores "${mixed[@]}" +run "the provider flavor links both" "app:providerDebugRuntimeClasspath=openiap-core,provider" :app:printProviderStores "${mixed[@]}" +run "unset properties leave a play task" "app:playDebugRuntimeClasspath=openiap-google" :app:printPlayStores +run "but the provider flavor needs them" "fail:the provider platform flavor requires openiapStore and openiapProvider" :app:printProviderStores +case_fixture="$fixture" echo echo "Store plugin: $passed passed, $failed failed" diff --git a/packages/google/scripts/verify-store-resolver.sh b/packages/google/scripts/verify-store-resolver.sh index de3b07795..7743c891f 100755 --- a/packages/google/scripts/verify-store-resolver.sh +++ b/packages/google/scripts/verify-store-resolver.sh @@ -35,7 +35,7 @@ run() { fi else actual=$(printf '%s\n' "$output" \ - | sed -n 's/.*FIXTURE store=\([a-z]*\) source=\([a-z]*\).*/\1\/\2/p' \ + | sed -n 's/.*FIXTURE store=\([a-z0-9._-]*\) source=\([a-z]*\).*/\1\/\2/p' \ | tail -1) if [[ -z "$actual" ]]; then actual="no resolution (exit=$status): ${output//$'\n'/ }" @@ -104,6 +104,19 @@ clear_device() { echo "Store resolver regression suite" +echo "community provider" +clear_device +run "external store id" fixture-store/explicit assembleDebug -PopeniapStore=fixture-store -PopeniapProvider=community.store:fake:1.0.0 +run "unregistered id" another/explicit assembleRelease -PopeniapStore=another -PopeniapProvider=community.store:fake:1.0.0 +run "external without provider" "fail:unknown openiapStore" assembleDebug -PopeniapStore=fixture +run "provider without id" "fail:requires an explicit community" assembleDebug -PopeniapProvider=community.store:fake:1.0.0 +run "provider with official" "fail:requires an explicit community" assembleDebug -PopeniapStore=play -PopeniapProvider=community.store:fake:1.0.0 +run "dynamic provider" "fail:fixed version" assembleDebug -PopeniapStore=fixture -PopeniapProvider=community.store:fake:1.+ +run "snapshot provider" "fail:fixed version" assembleDebug -PopeniapStore=fixture -PopeniapProvider=community.store:fake:1.0.0-SNAPSHOT +run "official artifact as provider" "fail:must name a community provider" assembleDebug -PopeniapStore=fixture -PopeniapProvider=io.github.hyochan.openiap:openiap-core:1.0.0 +run "a legacy flag beside a community id fails" "fail:conflicts with horizonEnabled=true" assembleDebug -PopeniapStore=fixture -PopeniapProvider=community.store:fake:1.0.0 -PhorizonEnabled=true +run "wrong store task" "fail:conflicts with" assembleHorizonRelease -PopeniapStore=fixture -PopeniapProvider=community.store:fake:1.0.0 + echo "explicit pin" clear_device run "openiapStore=horizon" horizon/explicit assembleDebug -PopeniapStore=horizon @@ -114,6 +127,7 @@ run "alias fire-os" amazon/explicit assembleDebug -Popen run "alias googleplay" play/explicit assembleDebug -PopeniapStore=googleplay run "auto is not a pin" play/default assembleDebug -PopeniapStore=auto run "a value that names no store fails" "fail:unknown openiapStore" assembleDebug -PopeniapStore=bogus +run "openiapStore=apple fails" "fail:unknown openiapStore" assembleDebug -PopeniapStore=apple echo "store flags" run "horizonEnabled=true" horizon/explicit assembleDebug -PhorizonEnabled=true diff --git a/packages/google/settings.gradle.kts b/packages/google/settings.gradle.kts index 9a0edff08..2d92e7422 100644 --- a/packages/google/settings.gradle.kts +++ b/packages/google/settings.gradle.kts @@ -25,4 +25,8 @@ dependencyResolutionManagement { rootProject.name = "openiap-google" include(":openiap") +include(":openiap-core") +project(":openiap-core").projectDir = file("core") +include(":openiap-conformance") +project(":openiap-conformance").projectDir = file("../conformance/android") include(":Example") diff --git a/scripts/agent/bun.lock b/scripts/agent/bun.lock index aed589529..409f15a30 100644 --- a/scripts/agent/bun.lock +++ b/scripts/agent/bun.lock @@ -23,6 +23,7 @@ }, }, "overrides": { + "axios": "1.20.0", "langsmith": "0.8.10", "tmp": "0.2.7", "uuid": "11.1.1", @@ -116,7 +117,7 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "axios": ["axios@1.19.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw=="], + "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/scripts/agent/compile-context.ts b/scripts/agent/compile-context.ts index 3d70ea9b8..d55a2acf0 100644 --- a/scripts/agent/compile-context.ts +++ b/scripts/agent/compile-context.ts @@ -275,6 +275,21 @@ provides a server-side contract and conformance artifacts, not a mobile SDK or a hosted backend. IAPKit is one backend implementation, not a requirement for using OpenIAP SDKs. +For a community store provider, read https://openiap.dev/docs/guides/store-providers. +Its source walkthrough, actual example screens and acceptance brief reference +https://github.com/hyodotdev/openiap-google-amazon-community. Read that repository's +README, provenance, pinned input and VERIFICATION.md before building. It adapts the +upstream Amazon binding as an educational packaging example through the public Expo SDK. +For FireOS apps, recommend the official openiap-google-amazon integration at +https://openiap.dev/docs/setup/store/amazon. Expo registry checks and App Tester +simulation do not establish production readiness or registry parity across every framework. +Select the provider with its community id and fixed coordinates, preserve the +original receipt, and configure an explicit backend verification adapter. A new +store reports store=unknown with its own storeId; a provider serving an existing +store reports that store's canonical storeId and legacy store value. Pending or +rejected purchases remain unfinished. Run public conformance, consumer checks and +store testing separately. + Follow one purchase at https://openiap.dev/commerce-protocol/getting-started. Select Apple, Google Play, Amazon or Meta Horizon there; the chosen store stays with the six-step flow and its build brief. For Quest, open that page and @@ -545,7 +560,7 @@ Requires .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. env for EAS and release builds, which have no device to follow. \`modules.amazon.fireOS\` also pins Amazon, ahead of the device. The \`EXPO_IAP_*\` environment flags are deprecated and removed in expo-iap - 6.0.0; an \`app.config.js\` can read the EAS profile env and set the plugin + 7.0.0; an \`app.config.js\` can read the EAS profile env and set the plugin option instead. The config plugin otherwise carries store values only: \`android.horizon.appId\`, \`android.amazon.appstoreKey\`, the opt-in \`modules.onside\`, and optional \`android.amazon.vegaOS\` metadata. A diff --git a/scripts/agent/package.json b/scripts/agent/package.json index b663e3c88..d86d6ec39 100644 --- a/scripts/agent/package.json +++ b/scripts/agent/package.json @@ -34,6 +34,7 @@ "overrides": { "langsmith": "0.8.10", "tmp": "0.2.7", - "uuid": "11.1.1" + "uuid": "11.1.1", + "axios": "1.20.0" } } diff --git a/scripts/assert-lcov-coverage.mjs b/scripts/assert-lcov-coverage.mjs index 6de674eae..04a3dfe52 100644 --- a/scripts/assert-lcov-coverage.mjs +++ b/scripts/assert-lcov-coverage.mjs @@ -2,8 +2,295 @@ import fs from "node:fs"; import path from "node:path"; +import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +const repositoryRoot = path.resolve(fileURLToPath(import.meta.url), "../.."); + +function yamlBlock(source, key, indent) { + const lines = source.split(/\r?\n/); + const starts = lines.flatMap((line, index) => + line === `${" ".repeat(indent)}${key}:` ? [index] : [], + ); + if (starts.length !== 1) + throw new Error(`Coverage policy requires exactly one block: ${key}`); + const start = starts[0]; + const end = lines.findIndex( + (line, index) => + index > start && + line.trim() && + !line.trimStart().startsWith("#") && + line.match(/^ */)[0].length <= indent, + ); + return lines.slice(start + 1, end < 0 ? undefined : end).join("\n"); +} + +function yamlStrings(source, key, indent) { + return yamlBlock(source, key, indent) + .split("\n") + .filter((line) => line.trimStart().startsWith("- ")) + .map((line) => { + const value = line.trim().slice(2); + return value.startsWith('"') ? JSON.parse(value) : value; + }); +} + +export function readCoveragePolicy(source, component) { + if (!/^[a-z0-9-]+$/u.test(component)) { + throw new Error(`Invalid coverage component: ${component}`); + } + const parser = yamlBlock(yamlBlock(source, "parsers", 0), "lcov", 2); + const partials = parser + .split("\n") + .filter((line) => line.startsWith(" partials_as_hits:")); + if (partials.length !== 1 || partials[0] !== " partials_as_hits: true") { + throw new Error("Local coverage requires partials_as_hits: true"); + } + // The parity audit constrains this Codecov YAML shape. + const status = yamlBlock(yamlBlock(source, "coverage", 0), "status", 2); + const rules = ["project", "patch"].map((kind) => { + const block = yamlBlock(yamlBlock(status, kind, 4), component, 6); + const targets = [ + ...block.matchAll(/^ target: (\d+(?:\.\d+)?)%$/gmu), + ]; + const fields = block.split("\n"); + if ( + targets.length !== 1 || + fields.filter((line) => line.startsWith(" target:")).length !== + 1 || + fields.filter((line) => line.startsWith(" threshold:")).length !== + 1 || + !fields.includes(" threshold: 0%") + ) { + throw new Error(`Invalid ${kind} coverage target for ${component}`); + } + const minimum = Number(targets[0][1]); + if (minimum < 0 || minimum > 100) { + throw new Error(`Invalid ${kind} coverage minimum: ${minimum}`); + } + const paths = block.includes(" paths:") + ? yamlStrings(block, "paths", 8) + : yamlStrings(block, "flags", 8).flatMap((flag) => + yamlStrings( + yamlBlock(yamlBlock(source, "flags", 0), flag, 2), + "paths", + 4, + ), + ); + if (!paths.length) throw new Error(`Empty coverage scope for ${component}`); + return { minimum, paths }; + }); + if (JSON.stringify(rules[0].paths) !== JSON.stringify(rules[1].paths)) { + throw new Error(`Project and patch scopes differ for ${component}`); + } + return { + projectMinimum: rules[0].minimum, + patchMinimum: rules[1].minimum, + paths: rules[0].paths, + ignore: yamlStrings(source, "ignore", 0), + }; +} + +function matchesGlob(filename, pattern) { + let expression = ""; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (pattern.slice(index, index + 3) === "**/") { + expression += "(?:.*/)?"; + index += 2; + } else if (pattern.slice(index, index + 2) === "**") { + expression += ".*"; + index += 1; + } else if (character === "*") { + expression += "[^/]*"; + } else { + expression += character.replace(/[.+?^${}()|[\]\\]/gu, "\\$&"); + } + } + return new RegExp(`^${expression}$`, "u").test(filename); +} + +export function readChangedLines(patch) { + const files = new Map(); + let filename; + let nextLine; + let deleted = false; + for (const line of patch.split(/\r?\n/)) { + if (line.startsWith("diff --git ")) { + filename = undefined; + nextLine = undefined; + deleted = false; + } else if (nextLine === undefined && line.startsWith("+++ ")) { + const value = line.slice(4).split("\t")[0]; + const decoded = value.startsWith('"') ? JSON.parse(value) : value; + deleted = decoded === "/dev/null"; + if (decoded !== "/dev/null") { + filename = decoded.replace(/^b\//u, ""); + files.set(filename, new Set()); + } + } else if (line.startsWith("@@ ")) { + if (deleted) continue; + const match = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/u); + if (!match || !filename) throw new Error("Invalid coverage diff hunk"); + nextLine = Number(match[1]); + } else if (nextLine !== undefined && line.startsWith("+")) { + files.get(filename).add(nextLine++); + } else if (nextLine !== undefined && line.startsWith(" ")) { + nextLine += 1; + } + } + return files; +} + +function readLcovRecords(source) { + const records = []; + let record; + for (const line of source.split(/\r?\n/)) { + if (line.startsWith("SF:")) { + if (record) throw new Error("Unterminated LCOV record"); + record = { filename: line.slice(3), lines: new Map() }; + } else if (line.startsWith("DA:")) { + const match = line.match(/^DA:(\d+),(\d+)(?:,[^,]+)?$/u); + if (!record || !match || Number(match[1]) < 1) { + throw new Error(`Invalid LCOV line data: ${line}`); + } + const number = Number(match[1]); + const hits = Number(match[2]); + if (!Number.isSafeInteger(number) || !Number.isSafeInteger(hits)) { + throw new Error(`Invalid LCOV line data: ${line}`); + } + record.lines.set(number, Math.max(record.lines.get(number) ?? 0, hits)); + } else if (/^(LF|LH):/u.test(line)) { + const match = line.match(/^(LF|LH):(\d+)$/u); + if (!record || !match) + throw new Error(`Invalid LCOV line total: ${line}`); + record[match[1]] = Number(match[2]); + } else if (line === "end_of_record") { + if ( + !record || + record.LF !== record.lines.size || + record.LH !== + [...record.lines.values()].filter((hits) => hits > 0).length + ) { + throw new Error("LCOV LF/LH totals disagree with executable line data"); + } + records.push(record); + record = undefined; + } + } + if (record || !records.length) + throw new Error("Missing complete LCOV records"); + return records; +} + +export function readComponentCoverage( + source, + patch, + policy, + sourceDirectory, + root = repositoryRoot, +) { + const changed = readChangedLines(patch); + const measured = new Map(); + for (const record of readLcovRecords(source)) { + const absolute = path.resolve(sourceDirectory, record.filename); + const filename = path.relative(root, absolute).replaceAll("\\", "/"); + if ( + !record.filename.trim() || + filename === ".." || + filename.startsWith("../") + ) { + throw new Error( + `LCOV source is outside the workspace: ${record.filename}`, + ); + } + if ( + !policy.paths.some((pattern) => matchesGlob(filename, pattern)) || + policy.ignore.some((pattern) => matchesGlob(filename, pattern)) + ) { + continue; + } + const lines = measured.get(filename) ?? new Map(); + for (const [number, hits] of record.lines) { + lines.set(number, Math.max(lines.get(number) ?? 0, hits)); + } + measured.set(filename, lines); + } + const project = { found: 0, hit: 0 }; + const diff = { found: 0, hit: 0 }; + for (const [filename, lines] of measured) { + for (const [number, hits] of lines) { + project.found += 1; + project.hit += Number(hits > 0); + if (changed.get(filename)?.has(number)) { + diff.found += 1; + diff.hit += Number(hits > 0); + } + } + } + if (project.found === 0) + throw new Error("No executable lines in coverage scope"); + project.percentage = (project.hit / project.found) * 100; + diff.percentage = diff.found ? (diff.hit / diff.found) * 100 : null; + for (const [kind, coverage, minimum] of [ + ["Project", project, policy.projectMinimum], + ["Patch", diff, policy.patchMinimum], + ]) { + if (coverage.percentage !== null && coverage.percentage < minimum) { + throw new Error( + `${kind} coverage ${coverage.percentage.toFixed(2)}% (${coverage.hit}/${coverage.found}) is below ${minimum.toFixed(2)}%`, + ); + } + } + return { project, patch: diff }; +} + +export function normalizeCoverageBase(base) { + if (!base) + throw new Error("COVERAGE_BASE_REF is required for patch coverage"); + return /^0{40}$/u.test(base) ? "HEAD^" : base; +} + +function assertComponentCoverage(reportPath, component, base) { + const resolvedBase = execFileSync( + "git", + [ + "rev-parse", + "--verify", + "--end-of-options", + `${normalizeCoverageBase(base)}^{commit}`, + ], + { cwd: repositoryRoot, encoding: "utf8" }, + ).trim(); + const patch = execFileSync( + "git", + [ + "-c", + "core.quotePath=false", + "diff", + "--no-ext-diff", + "--no-textconv", + "--find-renames", + "--unified=0", + "--inter-hunk-context=0", + resolvedBase, + "HEAD", + "--", + ], + { cwd: repositoryRoot, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, + ); + const policy = readCoveragePolicy( + fs.readFileSync(path.join(repositoryRoot, "codecov.yml"), "utf8"), + component, + ); + return readComponentCoverage( + fs.readFileSync(reportPath, "utf8"), + patch, + policy, + process.cwd(), + ); +} + function sourcePathSegments(sourcePath) { return path.posix .normalize(sourcePath.replaceAll("\\", "/")) @@ -90,12 +377,28 @@ if (isMain) { const [reportPath, minimumInput, sourcePrefix] = process.argv.slice(2); if (!reportPath || minimumInput === undefined) { console.error( - "Usage: node scripts/assert-lcov-coverage.mjs [source-prefix]", + "Usage: node scripts/assert-lcov-coverage.mjs [source-prefix] | --component ", ); process.exit(2); } try { + if (minimumInput === "--component") { + const coverage = assertComponentCoverage( + reportPath, + sourcePrefix, + process.env.COVERAGE_BASE_REF, + ); + console.log( + `${sourcePrefix} project coverage: ${coverage.project.percentage.toFixed(2)}% (${coverage.project.hit}/${coverage.project.found})`, + ); + console.log( + coverage.patch.percentage === null + ? `${sourcePrefix} patch coverage: N/A (no changed executable lines)` + : `${sourcePrefix} patch coverage: ${coverage.patch.percentage.toFixed(2)}% (${coverage.patch.hit}/${coverage.patch.found})`, + ); + process.exit(0); + } const minimum = Number(minimumInput); const coverage = assertLcovLineCoverage(reportPath, minimum, sourcePrefix); const scope = diff --git a/scripts/assert-lcov-coverage.test.mjs b/scripts/assert-lcov-coverage.test.mjs index 3d772ef22..c1d283554 100644 --- a/scripts/assert-lcov-coverage.test.mjs +++ b/scripts/assert-lcov-coverage.test.mjs @@ -1,14 +1,248 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, it } from "node:test"; import { assertLcovLineCoverage, + normalizeCoverageBase, + readChangedLines, + readComponentCoverage, + readCoveragePolicy, readLcovLineCoverage, } from "./assert-lcov-coverage.mjs"; +describe("local project and patch coverage", () => { + const root = "/workspace/openiap"; + const directory = `${root}/libraries/expo-iap`; + const config = readFileSync( + new URL("../codecov.yml", import.meta.url), + "utf8", + ); + const policy = readCoveragePolicy(config, "expo-iap"); + function record(filename, hits) { + return [ + `SF:${filename}`, + ...hits.map((count, index) => `DA:${index + 1},${count}`), + `LF:${hits.length}`, + `LH:${hits.filter((count) => count > 0).length}`, + "end_of_record", + ].join("\n"); + } + function patch(filename, start, count) { + return [ + `diff --git a/${filename} b/${filename}`, + `--- a/${filename}`, + `+++ b/${filename}`, + `@@ -${start},${count} +${start},${count} @@`, + ...Array.from({ length: count }, () => "+changed"), + ].join("\n"); + } + const filename = "libraries/expo-iap/src/index.ts"; + + it("uses canonical project and patch targets, flag scopes and ignores", () => { + assert.equal(policy.projectMinimum, 90); + assert.equal(policy.patchMinimum, 90); + assert.deepEqual(policy.paths, ["libraries/expo-iap/src/**"]); + assert.ok(policy.ignore.includes("libraries/expo-iap/src/types.ts")); + const convex = readCoveragePolicy(config, "iapkit-convex"); + assert.equal(convex.patchMinimum, 48); + assert.deepEqual(convex.paths, ["packages/kit/convex/**"]); + }); + + it("rejects changed uncovered lines even when project coverage passes", () => { + const source = record("src/index.ts", [...Array(9).fill(1), 0]); + assert.throws( + () => + readComponentCoverage( + source, + patch(filename, 10, 1), + policy, + directory, + root, + ), + /Patch coverage 0\.00%.*below 90\.00%/, + ); + }); + + it("enforces project coverage when every changed line is covered", () => { + assert.throws( + () => + readComponentCoverage( + record("src/index.ts", [1, 0]), + patch(filename, 1, 1), + policy, + directory, + root, + ), + /Project coverage 50\.00%.*below 90\.00%/, + ); + }); + + it("accepts exact thresholds and positive partial hits", () => { + const coverage = readComponentCoverage( + record("src/index.ts", [...Array(9).fill(1), 0]), + patch(filename, 1, 10), + policy, + directory, + root, + ); + assert.deepEqual(coverage.project, { found: 10, hit: 9, percentage: 90 }); + assert.deepEqual(coverage.patch, { found: 10, hit: 9, percentage: 90 }); + }); + + it("excludes generated files from project and patch totals", () => { + const coverage = readComponentCoverage( + `${record("src/index.ts", [1])}\n${record("src/types.ts", [0, 0])}`, + `${patch(filename, 1, 1)}\n${patch("libraries/expo-iap/src/types.ts", 1, 2)}`, + policy, + directory, + root, + ); + assert.deepEqual(coverage.patch, { found: 1, hit: 1, percentage: 100 }); + assert.equal(coverage.project.found, 1); + }); + + it("reports no executable diff explicitly without exempting the project gate", () => { + const coverage = readComponentCoverage( + record("src/index.ts", [1]), + patch(filename, 2, 1), + policy, + directory, + root, + ); + assert.deepEqual(coverage.patch, { found: 0, hit: 0, percentage: null }); + assert.throws( + () => + readComponentCoverage( + record("src/index.ts", [0]), + "", + policy, + directory, + root, + ), + /Project coverage/, + ); + }); + + it("counts only added new-side lines, including spaced and Unicode paths", () => { + const diff = [ + "diff --git a/source ü.ts b/source ü.ts", + "--- a/source ü.ts", + "+++ b/source ü.ts", + "@@ -1,3 +1,3 @@", + " same", + "-removed", + "+added", + " same", + "@@ -10,2 +10,0 @@", + "-removed", + "-removed", + ].join("\n"); + assert.deepEqual([...readChangedLines(diff).get("source ü.ts")], [2]); + }); + + it("merges repeated line records using positive hits", () => { + const coverage = readComponentCoverage( + `${record("src/index.ts", [0])}\n${record("src/index.ts", [2])}`, + patch(filename, 1, 1), + policy, + directory, + root, + ); + assert.deepEqual(coverage.project, { found: 1, hit: 1, percentage: 100 }); + }); + + it("excludes deleted files from the new-side diff", () => { + const deleted = + "diff --git a/old.ts b/old.ts\n--- a/old.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n"; + assert.equal(readChangedLines(deleted).size, 0); + }); + + it("fails malformed, empty, unterminated or unmapped reports", () => { + for (const source of [ + "", + record("src/index.ts", [1]).replace("DA:1,1", "DA:0,1"), + record("src/index.ts", [1]).replace("DA:1,1", "DA:1,-1"), + record("src/index.ts", [1]).replace("LF:1", "LF:2"), + record("src/index.ts", [1]).replace("end_of_record", ""), + record("/another/repo/src/index.ts", [1]), + ]) { + assert.throws(() => + readComponentCoverage(source, "", policy, directory, root), + ); + } + }); + + it("fails missing targets and unsupported policy changes", () => { + assert.throws(() => readCoveragePolicy(config, "unknown-component")); + assert.throws(() => + readCoveragePolicy( + config.replaceAll("threshold: 0%", "threshold: 1%"), + "expo-iap", + ), + ); + assert.throws(() => + readCoveragePolicy( + config.replace("partials_as_hits: true", "partials_as_hits: false"), + "expo-iap", + ), + ); + }); + + it("rejects duplicate blocks and malformed or duplicate policy fields", () => { + for (const source of [ + config.replace( + " react-native-iap:", + " react-native-iap:\n target: 10%\n threshold: 0%\n flags:\n - react-native-iap\n react-native-iap:", + ), + config.replace(" threshold: 0%", " threshold: 0%not-valid"), + config.replace( + " target: 90%", + " target: 10%\n target: 90%", + ), + config.replace( + " threshold: 0%", + " threshold: 0%\n threshold: 1%", + ), + config.replace( + " partials_as_hits: true", + " partials_as_hits: true\n partials_as_hits: false", + ), + ]) { + assert.throws(() => readCoveragePolicy(source, "react-native-iap")); + } + }); + + it("rejects outside-workspace and empty sources even alongside valid records", () => { + for (const filename of [ + "/another/repo/src/index.ts", + "../../../outside.ts", + "", + ]) { + assert.throws( + () => + readComponentCoverage( + `${record("src/index.ts", [1])}\n${record(filename, [1])}`, + "", + policy, + directory, + root, + ), + /outside the workspace/, + ); + } + }); + + it("normalizes initial branch pushes without weakening missing-base checks", () => { + assert.equal(normalizeCoverageBase("0".repeat(40)), "HEAD^"); + assert.equal(normalizeCoverageBase("a".repeat(40)), "a".repeat(40)); + assert.throws(() => normalizeCoverageBase(""), /required/); + assert.throws(() => normalizeCoverageBase(undefined), /required/); + }); +}); + describe("LCOV line coverage guard", () => { const temporaryPaths = []; diff --git a/scripts/audit-ci-path-filters.mjs b/scripts/audit-ci-path-filters.mjs index b8fac54a6..50a16b441 100644 --- a/scripts/audit-ci-path-filters.mjs +++ b/scripts/audit-ci-path-filters.mjs @@ -136,6 +136,26 @@ const ALL_SWIFT_WRAPPERS = [ ]; export const cases = Object.freeze([ + { + name: "apple-public-provider-conformance", + files: ["packages/conformance/apple/Sources/ProviderConformance.swift"], + jobs: [ + "ci:test-android", + "ci:test-gql", + "ci:test-ios", + "ci:test-ios-compiler-boundaries", + "codeql:analyze-swift", + ], + }, + { + name: "apple-public-package-products", + files: ["Package.swift"], + jobs: [ + "ci:test-ios", + "ci:test-ios-compiler-boundaries", + "codeql:analyze-swift", + ], + }, { name: "apple-core-docs-only", files: [ diff --git a/scripts/audit-deprecation-schedule.test.mjs b/scripts/audit-deprecation-schedule.test.mjs index 870ed0bb4..f4cca649c 100644 --- a/scripts/audit-deprecation-schedule.test.mjs +++ b/scripts/audit-deprecation-schedule.test.mjs @@ -551,3 +551,29 @@ test("the release gate command passes a package that no rule names", () => { ); assert.match(output, /godot-iap may release with version=patch/); }); + +test("the compiled AI context names the scheduled expo-iap flag removal major", () => { + const rule = scheduledRemovalRules.find( + (entry) => entry.label === "expo-iap environment flags", + ); + assert.ok(rule, "scheduled removal rule is missing"); + const expo = rule.packages.find((entry) => entry.name === "expo-iap"); + assert.ok(expo, "expo-iap package entry is missing"); + const removal = `${expo.major + 1}.0.0`.replaceAll(".", "\\."); + const namesRemoval = new RegExp(`removed in expo-iap\\s+${removal}`); + for (const file of [ + "scripts/agent/compile-context.ts", + "packages/docs/public/llms-full.txt", + ]) { + const content = fs.readFileSync(path.join(repoRoot, file), "utf8"); + assert.match(content, namesRemoval, file); + } + const migration = fs.readFileSync( + path.join(repoRoot, "packages/docs/src/pages/docs/updates/migration.tsx"), + "utf8", + ); + assert.ok( + migration.includes(`Removal in expo-iap ${expo.major + 1}.0`), + "migration catalog names a different removal major", + ); +}); diff --git a/scripts/audit-godot-binary-digests.mjs b/scripts/audit-godot-binary-digests.mjs index 91babeee9..e86d31d21 100644 --- a/scripts/audit-godot-binary-digests.mjs +++ b/scripts/audit-godot-binary-digests.mjs @@ -2,7 +2,7 @@ // The godot-iap addon ships native frameworks that are committed to the // repository, and the release does not rebuild them, so nothing in the release // pipeline otherwise records or verifies the bytes users execute. This audit -// pins them. +// pins them, plus the committed Android archives an export embeds as-is. import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -11,6 +11,10 @@ import { pathToFileURL } from "node:url"; export const GODOT_ROOT = "libraries/godot-iap"; export const DIGEST_MANIFEST = `${GODOT_ROOT}/prebuilt-binaries.sha256`; const BINARY_ROOT = `${GODOT_ROOT}/addons/godot-iap/bin`; +const ANDROID_ROOT = `${GODOT_ROOT}/addons/godot-iap/android`; +// The Android directory also holds the export metadata the build rewrites on +// every version bump, so only the archives are pinned. +const ANDROID_PAYLOAD = /\.aar$/; // Identify payloads by Mach-O magic rather than by extension, so editor // metadata and future file types classify themselves. const MACH_O_MAGIC = new Set([ @@ -71,16 +75,17 @@ const walk = (dir, acc = []) => { return acc; }; +const relativeToGodotRoot = (repoRoot, file) => + path + .relative(path.resolve(repoRoot, GODOT_ROOT), file) + .split(path.sep) + .join("/"); + // Every file under the binary root, relative to GODOT_ROOT. export function listBinaryRootFiles(repoRoot) { const root = path.resolve(repoRoot, BINARY_ROOT); return walk(root) - .map((file) => - path - .relative(path.resolve(repoRoot, GODOT_ROOT), file) - .split(path.sep) - .join("/"), - ) + .map((file) => relativeToGodotRoot(repoRoot, file)) .sort(); } @@ -88,12 +93,16 @@ export function listTrackedBinaries(repoRoot) { const root = path.resolve(repoRoot, BINARY_ROOT); return walk(root) .filter((file) => isMachO(file)) - .map((file) => - path - .relative(path.resolve(repoRoot, GODOT_ROOT), file) - .split(path.sep) - .join("/"), - ) + .map((file) => relativeToGodotRoot(repoRoot, file)) + .sort(); +} + +// The committed Android archives, relative to GODOT_ROOT. +export function listAndroidBinaries(repoRoot) { + const root = path.resolve(repoRoot, ANDROID_ROOT); + return walk(root) + .filter((file) => ANDROID_PAYLOAD.test(file)) + .map((file) => relativeToGodotRoot(repoRoot, file)) .sort(); } @@ -110,11 +119,12 @@ export function digestOf(repoRoot, relativeFile) { // excused nor renderable otherwise, so `--write` could not produce a manifest // the audit accepts. export function listFilesRequiringDigests(repoRoot) { - return listBinaryRootFiles(repoRoot).filter((file) => { + const binaries = listBinaryRootFiles(repoRoot).filter((file) => { if (!NON_PAYLOAD.some((pattern) => pattern.test(file))) return true; // An excused name still needs a digest when it holds executable code. return isMachO(path.resolve(repoRoot, GODOT_ROOT, file)); }); + return [...binaries, ...listAndroidBinaries(repoRoot)].sort(); } export function collectGodotBinaryDigestFailures(repoRoot) { const manifestPath = path.resolve(repoRoot, DIGEST_MANIFEST); @@ -144,7 +154,10 @@ export function collectGodotBinaryDigestFailures(repoRoot) { // Scan every shipped file rather than only the ones that still look like // Mach-O: corrupting an executable would otherwise drop it from this set, // and deleting its digest line would then leave it unnoticed by both checks. - for (const file of listBinaryRootFiles(repoRoot)) { + for (const file of [ + ...listBinaryRootFiles(repoRoot), + ...listAndroidBinaries(repoRoot), + ]) { if (recorded.has(file)) continue; // The exclusion is by name, so it has to be checked against content too: // a Mach-O copied to Payload.gdextension would otherwise be excused by @@ -212,7 +225,7 @@ if ( ); process.exit(1); } - console.log( - `Godot prebuilt binary digests verified (${listTrackedBinaries(repoRoot).length} binaries).`, - ); + const binaries = + listTrackedBinaries(repoRoot).length + listAndroidBinaries(repoRoot).length; + console.log(`Godot prebuilt binary digests verified (${binaries} binaries).`); } diff --git a/scripts/audit-godot-binary-digests.test.mjs b/scripts/audit-godot-binary-digests.test.mjs index b6a384a1c..adfa674c7 100644 --- a/scripts/audit-godot-binary-digests.test.mjs +++ b/scripts/audit-godot-binary-digests.test.mjs @@ -8,6 +8,7 @@ import { collectGodotBinaryDigestFailures, renderDigestManifest, digestOf, + listAndroidBinaries, listTrackedBinaries, parseDigestManifest, } from "./audit-godot-binary-digests.mjs"; @@ -29,6 +30,11 @@ const stageFixture = (scratch) => { path.join(target, "addons/godot-iap/bin"), { recursive: true }, ); + fs.cpSync( + path.join(repoRoot, "libraries/godot-iap/addons/godot-iap/android"), + path.join(target, "addons/godot-iap/android"), + { recursive: true }, + ); return target; }; @@ -44,13 +50,23 @@ test("every binary that ships is covered", () => { .filter((entry) => entry.digest) .map((entry) => entry.file), ); - const shipped = listTrackedBinaries(repoRoot); + const shipped = [ + ...listTrackedBinaries(repoRoot), + ...listAndroidBinaries(repoRoot), + ]; assert.ok(shipped.length > 0, "no binaries were discovered"); for (const file of shipped) { assert.ok(covered.has(file), `${file} has no recorded digest`); } }); +test("exactly the two Android archives are pinned", () => { + assert.deepEqual(listAndroidBinaries(repoRoot), [ + "addons/godot-iap/android/GodotIap.debug.aar", + "addons/godot-iap/android/GodotIap.release.aar", + ]); +}); + test("comments and blank lines are ignored, malformed lines are not", () => { const entries = parseDigestManifest( ["# header", "", `${"a".repeat(64)} bin/x`, "garbage line"].join("\n"), @@ -84,6 +100,29 @@ test("a changed binary is reported with both digests", () => { } }); +test("a changed Android archive is reported with both digests", () => { + const file = listAndroidBinaries(repoRoot)[0]; + const real = digestOf(repoRoot, file); + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "godot-digests-")); + try { + // Copy the addon tree, then rewrite the manifest with one wrong digest. + stageFixture(scratch); + const manifest = path.join(scratch, DIGEST_MANIFEST); + fs.writeFileSync( + manifest, + fs.readFileSync(manifest, "utf8").replace(real, "0".repeat(64)), + ); + const failures = collectGodotBinaryDigestFailures(scratch); + assert.equal(failures.length, 1); + assert.match( + failures[0], + new RegExp(`^${file} changed: recorded 0{64}, found ${real}$`), + ); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } +}); + test("corrupting a binary and deleting its line does not hide it", () => { // The protected set used to be derived from Mach-O magic, so replacing an // executable with other bytes removed it from the scan; deleting its digest diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 43a708c18..e839f1c94 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { parseDocument } from "yaml"; import { GENERATED_SYNC_MANIFEST, gqlPackageRelativePath, @@ -21,6 +22,8 @@ import { maskKotlinCommentsAndStrings, maskTypeScriptCommentsAndStrings, } from "./audit-purchase-payload-parity.mjs"; +import { storeBindings } from "../specs/client/store-registry.mjs"; +import { SUITE_VERSION } from "../packages/conformance/src/spec/suite-version.mjs"; import { sponsorBlockStart } from "./sync-sponsors.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -72,6 +75,11 @@ execFileSync( ], { stdio: "inherit" }, ); +execFileSync( + process.execPath, + ["--test", path.resolve(root, "scripts/store-registry.test.mjs")], + { stdio: "inherit" }, +); const failures = []; const EXPO_EXAMPLE_ROOT = "libraries/expo-iap/example"; @@ -314,7 +322,36 @@ function checkNoReflectionIntoOpenIap() { /\b(?:getMethod|getDeclaredMethod|getField|getDeclaredField|getConstructor|getDeclaredConstructor)\(|Class\.forName\(/; for (const source of sources) { for (const file of listTrackedFiles(source)) { - if (!/\.(?:kt|java)$/.test(file) || /\/(?:[a-z]+Test|test)[A-Za-z]*\//.test(file)) continue; + if ( + !/\.(?:kt|java)$/.test(file) || + /\/(?:[a-z]+Test|test)[A-Za-z]*\//.test(file) + ) + continue; + if ( + file === + "packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProvider.kt" + ) { + expectIncludes( + file, + [ + 'const val METADATA_KEY = "dev.hyo.openiap.PROVIDER"', + "Class.forName(className, true, context.classLoader)", + "OpenIapProviderFactory::class.java.isAssignableFrom(type)", + "type.getConstructor().newInstance() as OpenIapProviderFactory", + "catch (error: LinkageError)", + "validate(factory.descriptor)", + ], + "Provider discovery must validate the manifest factory and core compatibility", + ); + expectIncludes( + "packages/google/core/consumer-rules.pro", + [ + "-keep class * implements dev.hyo.openiap.OpenIapProviderFactory { public (); }", + ], + "Manifest factories must survive R8 in any package", + ); + continue; + } if (reflective.test(read(file))) { fail( `${file} looks up code by reflection, which R8 removes from release builds; call openiap directly`, @@ -671,8 +708,8 @@ function checkFrameworkCiAndCoverageBadges() { codecovTargetPath: "packages/kit/server", codecovConfigPush: false, coverageAssertions: [ - "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 server/", - "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 48 convex/", + "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info --component iapkit-server", + "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info --component iapkit-convex", ], componentId: "iapkit-server", componentName: "IAPKit Server", @@ -686,7 +723,6 @@ function checkFrameworkCiAndCoverageBadges() { testCommand: "run: bun run test:coverage", testJob: "verify", uploadFlag: "iapkit", - uploadName: "iapkit", workflowFile: "deploy-kit.yml", }, ]; @@ -875,6 +911,13 @@ function checkFrameworkCiAndCoverageBadges() { if (exists("codecov.yml")) { const codecovConfig = read("codecov.yml"); + const errors = parseDocument(codecovConfig).errors; + if (errors.length) { + fail( + `codecov.yml is invalid YAML: ${errors.map(({ message }) => message).join("; ")}`, + ); + return; + } const lcovParserBlock = [ "parsers:", " lcov:", @@ -970,6 +1013,70 @@ function checkFrameworkCiAndCoverageBadges() { } } + const reportActionPath = ".github/actions/coverage-report/action.yml"; + expectFile(reportActionPath); + if (exists(reportActionPath)) { + const reportAction = read(reportActionPath); + for (const needle of [ + "using: composite", + "uses: actions/upload-artifact@", + "name: coverage-${{ inputs.flag }}", + "path: ${{ github.workspace }}/${{ inputs.package-path }}/coverage/lcov.info", + "if-no-files-found: error", + "retention-days: 14", + "id: codecov-cli", + "working-directory: ${{ github.workspace }}", + 'timeout 120s scripts/install-security-tool.sh codecov "$RUNNER_TEMP/codecov-cli"', + 'exec timeout 120s "${BASH_SOURCE[0]}-cli" "$@"', + "id: codecov-upload", + "if: steps.codecov-cli.outcome == 'success'", + "uses: codecov/codecov-action@", + "binary: ${{ runner.temp }}/codecov", + "use_oidc: ${{ inputs.use-oidc }}", + "fail_ci_if_error: true", + "disable_search: true", + "files: coverage/lcov.info", + "flags: ${{ inputs.flag }}", + "network_prefix: ${{ inputs.package-path }}/", + "working-directory: ${{ inputs.package-path }}", + "steps.codecov-cli.outcome == 'failure' || steps.codecov-upload.outcome == 'failure'", + "::warning::Codecov reporting failed.", + "$GITHUB_STEP_SUMMARY", + ]) { + if (!reportAction.includes(needle)) { + fail(`${reportActionPath} must include ${JSON.stringify(needle)}`); + } + } + const parsedAction = parseDocument(reportAction); + const steps = parsedAction.toJS()?.runs?.steps; + if ( + parsedAction.errors.length || + !Array.isArray(steps) || + steps.length !== 4 + ) { + fail(`${reportActionPath} must define four valid composite steps`); + } else if ( + steps.some((step, index) => + [1, 2].includes(index) + ? step["continue-on-error"] !== true || + step.id !== ["codecov-cli", "codecov-upload"][index - 1] + : "continue-on-error" in step || ("if" in step && index === 0), + ) + ) { + fail( + `${reportActionPath} may soften only the Codecov installer and upload`, + ); + } + if ( + reportAction.indexOf("uses: actions/upload-artifact@") >= + reportAction.indexOf("id: codecov-cli") + ) { + fail( + `${reportActionPath} must archive coverage before optional reporting`, + ); + } + } + for (const contract of contracts) { const workflowPath = `.github/workflows/${contract.workflowFile}`; expectFile(workflowPath); @@ -997,22 +1104,18 @@ function checkFrameworkCiAndCoverageBadges() { continue; } const coverageAssertions = contract.coverageAssertions ?? [ - "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90", + `run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info --component ${contract.componentId}`, ]; for (const needle of [ "permissions:\n contents: read\n id-token: write", "fetch-depth: 0\n persist-credentials: false", contract.testCommand, ...coverageAssertions, - "uses: codecov/codecov-action@", - "use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}", - "fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}", - "disable_search: true", - "files: coverage/lcov.info", - `flags: ${contract.uploadFlag ?? contract.componentId}`, - `name: ${contract.uploadName ?? contract.componentId}`, - `network_prefix: ${contract.libraryPath}/`, - `working-directory: ${contract.libraryPath}`, + "COVERAGE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}", + "uses: ./.github/actions/coverage-report", + "use-oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}", + `flag: ${contract.uploadFlag ?? contract.componentId}`, + `package-path: ${contract.libraryPath}`, ]) { if (!testJob.includes(needle)) { fail( @@ -1021,14 +1124,38 @@ function checkFrameworkCiAndCoverageBadges() { } } if ( - (testJob.match(/codecov\/codecov-action@[0-9a-f]{40}\s+# v7/g) ?? []) + !workflowSource.includes( + `defaults:\n run:\n working-directory: ${contract.libraryPath}`, + ) && + !testJob.includes( + `defaults:\n run:\n working-directory: ${contract.libraryPath}`, + ) + ) { + fail( + `${workflowPath} coverage commands must run in ${contract.libraryPath}`, + ); + } + if ( + (testJob.match(/uses: \.\/\.github\/actions\/coverage-report/g) ?? []) .length !== 1 ) { fail( `${workflowPath} ${contract.testJob} must upload exactly one coverage report`, ); } - const uploadIndex = testJob.indexOf("uses: codecov/codecov-action@"); + const uploadIndex = testJob.indexOf( + "uses: ./.github/actions/coverage-report", + ); + if (/\n - /u.test(testJob.slice(uploadIndex))) { + fail( + `${workflowPath} must finish required checks before coverage reporting`, + ); + } + if (/continue-on-error:/u.test(testJob)) { + fail( + `${workflowPath} must keep tests, coverage, builds and security checks mandatory`, + ); + } for (const coverageAssertion of coverageAssertions) { const coverageAssertionIndex = testJob.indexOf(coverageAssertion); if (coverageAssertionIndex < 0 || uploadIndex <= coverageAssertionIndex) { @@ -1047,6 +1174,17 @@ function checkFrameworkCiAndCoverageBadges() { if (!eventBlock("pull_request").includes('- "codecov.yml"')) { fail(`${workflowPath} pull_request paths must include root codecov.yml`); } + for (const event of ["push", "pull_request"]) { + for (const trigger of [ + "scripts/install-security-tool.sh", + "scripts/assert-lcov-coverage.mjs", + ".github/actions/coverage-report/**", + ]) { + if (!eventBlock(event).includes(`- "${trigger}"`)) { + fail(`${workflowPath} ${event} paths must include ${trigger}`); + } + } + } const pushIncludesCodecov = eventBlock("push").includes('- "codecov.yml"'); if (contract.codecovConfigPush === false && pushIncludesCodecov) { fail( @@ -1061,7 +1199,9 @@ function checkFrameworkCiAndCoverageBadges() { const filterIndex = testJob.indexOf( "run: dart run tool/filter_coverage.dart", ); - const uploadIndex = testJob.indexOf("uses: codecov/codecov-action@"); + const uploadIndex = testJob.indexOf( + "uses: ./.github/actions/coverage-report", + ); if ( testIndex < 0 || filterIndex <= testIndex || @@ -1723,8 +1863,6 @@ const GOOGLE_FLAVOR_MODULES = [ "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt", ]; -const CONFORMANCE_SUITE_DIR = "src/conformanceTest/java"; - const GOOGLE_CONFORMANCE_ADAPTERS = { testPlay: "packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt", @@ -1738,6 +1876,21 @@ const GOOGLE_CONFORMANCE_ADAPTERS = { // behavior ids that appear in published reports, so losing one silently would // invalidate every claim made against it. function checkConformanceSuite() { + try { + execFileSync( + process.execPath, + [path.resolve(root, "scripts/generate-store-registry.mjs"), "--check"], + { stdio: "pipe" }, + ); + } catch (error) { + fail( + `Store registry audit failed: ${[error?.stdout, error?.stderr] + .filter(Boolean) + .map((output) => output.toString().trim()) + .join(" ")}`, + ); + } + for (const relativePath of [ "packages/conformance/package.json", "packages/conformance/README.md", @@ -1757,6 +1910,13 @@ function checkConformanceSuite() { expectFile(relativePath); } + // The README coordinate must track SUITE_VERSION, not a frozen copy. + expectIncludes( + "packages/conformance/README.md", + [`io.github.hyochan.openiap:openiap-conformance:${SUITE_VERSION}`], + "packages/conformance/README.md coordinate must match SUITE_VERSION", + ); + // Every MUST behavior needs at least one implementation covering it. try { execFileSync( @@ -1891,17 +2051,33 @@ function checkConformanceNotPublished() { ); } - // conformanceTest belongs to unit-test variants; wiring it into a shipped - // source set would compile it into the AAR. - const gradle = read("packages/google/openiap/build.gradle.kts"); - for (const sourceSet of ["main", "play", "horizon", "amazon"]) { - const block = new RegExp( - `named\\("${sourceSet}"\\)\\s*\\{([\\s\\S]*?)\\n\\s{8}\\}`, - ).exec(gradle)?.[1]; - if (block?.includes(CONFORMANCE_SUITE_DIR)) { - fail( - `packages/google/openiap/build.gradle.kts: ${sourceSet} must not include "${CONFORMANCE_SUITE_DIR}" — it would ship in the AAR`, - ); + // The suite lives in its own module now. The published openiap, openiap-core, + // and store-flavor artifacts must reach it (and JUnit) through test + // configurations only; an api/implementation edge would ship in the AAR/POM. + const shippedConfig = + "api|implementation|playApi|horizonApi|amazonApi|playImplementation|horizonImplementation|amazonImplementation"; + const shippedDep = new RegExp( + `(^|[^A-Za-z])(${shippedConfig})\\s*\\(|add\\(\\s*"(${shippedConfig})"\\s*,`, + ); + for (const buildFile of [ + "packages/google/openiap/build.gradle.kts", + "packages/google/core/build.gradle.kts", + ]) { + let inBlockComment = false; + for (const line of read(buildFile).split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("/*")) inBlockComment = true; + const code = inBlockComment || trimmed.startsWith("//") ? "" : trimmed; + if (trimmed.endsWith("*/")) inBlockComment = false; + if ( + code && + shippedDep.test(code) && + /openiap-conformance|junit/i.test(code) + ) { + fail( + `${buildFile}: conformance and JUnit stay in test configurations — "${trimmed}" would ship in the AAR`, + ); + } } } } @@ -1910,10 +2086,10 @@ function checkConformanceNotPublished() { // adapter or an unwired srcDir silently drops that store's coverage. function checkGoogleStoreConformanceSuite() { expectFile( - "packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt", + "packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceSuite.kt", ); expectFile( - "packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt", + "packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt", ); const gradle = read("packages/google/openiap/build.gradle.kts"); @@ -1934,9 +2110,16 @@ function checkGoogleStoreConformanceSuite() { const block = new RegExp( `named\\("${sourceSet}"\\)\\s*\\{([\\s\\S]*?)\\n\\s{8}\\}`, ).exec(gradle)?.[1]; - if (!block?.includes(CONFORMANCE_SUITE_DIR)) { + if (!block?.includes(`src/${sourceSet}/java`)) { fail( - `packages/google/openiap/build.gradle.kts: ${sourceSet} must include "${CONFORMANCE_SUITE_DIR}" so the shared conformance suite runs for that flavor`, + `packages/google/openiap/build.gradle.kts: ${sourceSet} must compile its conformance adapter`, + ); + } + if ( + !gradle.includes('testImplementation(project(":openiap-conformance"))') + ) { + fail( + "Every Google flavor must depend on the published conformance module in tests", ); } } @@ -2283,8 +2466,8 @@ function checkFlutter() { "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift", ]; - // The oldest openiap-apple with every symbol the plugin calls. - const flutterSwiftPackageFloor = "3.6.1"; + // Oldest openiap-apple with OpenIapFirstPurchaseNotice and the storeId/StoreIds identity API. + const flutterSwiftPackageFloor = "4.0.0"; for (const flutterSwiftPackage of flutterSwiftPackagePaths) { expectFile(flutterSwiftPackage); if (!exists(flutterSwiftPackage)) continue; @@ -2302,7 +2485,7 @@ function checkFlutter() { fail(`${flutterSwiftPackage} OpenIAP dependency version must be semver`); } else if (openIapDependencyVersion !== flutterSwiftPackageFloor) { fail( - `${flutterSwiftPackage} OpenIAP dependency floor must be ${flutterSwiftPackageFloor}, the first openiap-apple with OpenIapFirstPurchaseNotice`, + `${flutterSwiftPackage} OpenIAP dependency floor must be ${flutterSwiftPackageFloor}, the first openiap-apple with OpenIapFirstPurchaseNotice and the storeId/StoreIds identity API`, ); } if ( @@ -2467,13 +2650,16 @@ function checkFlutter() { ); expectIncludes( "libraries/expo-iap/src/index.ts", - ["restorePurchasesIOSNative"], + ["restorePurchasesNative"], "Expo restore helper routing", ); expectIncludes( "libraries/expo-iap/src/utils/restorePurchases.ts", - ["ExpoIapModule.USING_ONSIDE_SDK", "ExpoIapModule.restorePurchases"], - "Expo Onside restore routing", + [ + "typeof ExpoIapModule.restorePurchases", + "await ExpoIapModule.restorePurchases", + ], + "Expo provider and Onside restore routing", ); expectNotIncludes( "libraries/expo-iap/src/utils/restorePurchases.ts", @@ -2672,7 +2858,7 @@ function checkKmp() { expectIncludes( "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", [ - "requestPurchaseWithPayload(params.toJson().toObjCMap())", + "requestPurchaseWithJSON(params.toJson().toJsonStringIOS())", "requireIosSku(params)", "openIapModule.verifyPurchaseWithSku(sku)", "decodeActiveSubscriptionListPayloadIOS(result, subscriptionIds)", @@ -2708,7 +2894,7 @@ function checkKmp() { function checkIapkitAmazonContractWiring() { expectIncludes( - "packages/apple/Sources/OpenIapModule.swift", + "packages/apple/Sources/OpenIapStoreKitModule.swift", [ "expectedProductId: amazon.expectedProductId", "let environment = Self.iapkitEnvironment", @@ -2784,7 +2970,8 @@ function checkIapkitAmazonContractWiring() { [ "'expectedProductId':", "final environmentValue = itemMap['environment']", - "environment: environment,", + "RequestVerifyPurchaseWithIapkitResult.fromJson({", + "'environment': environment,", ], "Flutter IAPKit Amazon contract", ); @@ -2839,7 +3026,11 @@ function checkIapkitAmazonContractWiring() { ); expectIncludes( "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", - ['map["environment"] as? String', "environment = environment"], + [ + 'map["environment"] as? String', + '"environment" to environment', + '"storeId" to map["storeId"]', + ], "KMP iOS IAPKit response contract", ); } @@ -2901,6 +3092,8 @@ function checkApple() { rel(base, "Sources/OpenIapModule+ObjC.swift"), [ "func requestPurchaseWithPayload", + "func requestPurchaseWithJSON", + "requestPurchaseWithPayload(payload, completion: completion)", "OpenIapSerialization.requestPurchaseProps(from: payload)", "func getStorefrontWithCompletion", ], @@ -3065,7 +3258,7 @@ function checkMaui() { "RequestPurchaseWithPayload", "RequestPurchasePayload", "_module.GetStorefront(cb)", - "GetActiveSubscriptionsAsync(subscriptionIds)", + "_module.HasActiveSubscriptions(subscriptionIds?.ToArray(), cb)", ], "MAUI iOS requestPurchase bridge", ); @@ -3310,7 +3503,10 @@ function checkBillingChoiceFieldBindings() { ); expectIncludes( "libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt", - ["openIap.addConnectionStateListener(", "openIap.removeConnectionStateListener("], + [ + "openIap.addConnectionStateListener(", + "openIap.removeConnectionStateListener(", + ], "expo-iap subscribes to billing disconnect (#408)", ); expectNotIncludes( @@ -3460,19 +3656,24 @@ function checkBillingChoiceFieldBindings() { ); expectMatch( "libraries/react-native-iap/src/hooks/useIAP.ts", - /const restorePurchases[\s\S]*?const synced = await syncIOS\(\);\s*if \(!synced\)[\s\S]*?ErrorCode\.SyncError[\s\S]*?invokeOnError\(error\);\s*throw error;[\s\S]*?await getAvailablePurchasesInternal\(options\);/, + /const restorePurchases[\s\S]*?await restorePurchasesNative\(\);[\s\S]*?invokeOnError\(purchaseError\);\s*throw purchaseError;[\s\S]*?await getAvailablePurchasesInternal\(options\);/, "RN restore hook failure propagation", ); expectMatch( "libraries/react-native-iap/src/index.ts", - /export const restorePurchases[\s\S]*?const synced = await syncIOS\(\);\s*if \(!synced\)[\s\S]*?ErrorCode\.SyncError[\s\S]*?await getAvailablePurchases/, - "RN restore API false-sync rejection", + /export const restorePurchases[\s\S]*?await restorePurchasesNative\(\);[\s\S]*?await getAvailablePurchases/, + "RN restore API provider dispatch", + ); + expectMatch( + "libraries/react-native-iap/src/utils/restore-purchases.ts", + /const restored = await getNativeIapInstance\(\)\.restorePurchases\(\);[\s\S]*?if \(restored !== true\)[\s\S]*?ErrorCode\.SyncError/, + "RN restore native false-result rejection", ); expectIncludes( "libraries/react-native-iap/src/index.ts", [ "return convertApplePurchasesOrThrow(nitroPurchases);", - "return convertAndroidPurchasesOrThrow(allNitroPurchases);", + "return convertAndroidPurchasesOrThrow(nitroPurchases);", ], "RN platform-scoped authoritative purchase-list decoding", ); @@ -3523,7 +3724,7 @@ function checkBillingChoiceFieldBindings() { expectIncludes( "libraries/flutter_inapp_purchase/lib/helpers.dart", [ - "if (platformIsIOS && store != 'apple') return false;", + "if (platformIsIOS && store != 'apple' && store != 'unknown') return false;", "store != 'google' &&", "store != 'amazon' &&", "store != 'horizon'", @@ -4197,9 +4398,11 @@ function checkFrameworkDependencyHygiene() { ); const expectedDocsVersionMetadata = { _generatedBy: "scripts/sync-versions.sh", - clientProtocolPackageVersion: readJson("specs/client/package.json").version, - commerceProtocolPackageVersion: readJson("specs/commerce-protocol/package.json") + clientProtocolPackageVersion: readJson("specs/client/package.json") .version, + commerceProtocolPackageVersion: readJson( + "specs/commerce-protocol/package.json", + ).version, expoPackageVersion: readJson("libraries/expo-iap/package.json").version, reactNativePackageVersion: readJson( "libraries/react-native-iap/package.json", @@ -4373,6 +4576,7 @@ function checkFrameworkDependencyHygiene() { "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/apple/Sources/OpenIapStoreKitModule.swift", "packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt", ]) { expectNotIncludes( @@ -5221,7 +5425,7 @@ function checkFrameworkDependencyHygiene() { 'npm install -g "vercel@$VERCEL_CLI_VERSION"', 'FORCE_DEPLOY="${npm_config_force:-false}"', 'for arg in "$@"; do', - '-f|--force) FORCE_DEPLOY=true ;;', + "-f|--force) FORCE_DEPLOY=true ;;", "Unsupported argument:", "if ! ./scripts/sync-versions.sh; then", "if ! bun run typecheck; then", @@ -5529,8 +5733,11 @@ function checkFrameworkDependencyHygiene() { [ "release:", "runs-on: ubuntu-latest", - "./gradlew :openiap:assembleRelease --no-daemon --stacktrace", - "artifacts=(openiap/build/outputs/aar/*.aar openiap/build/libs/*.jar)", + "tasks=(:openiap:assembleRelease)", + "tasks+=(:openiap-core:assembleRelease)", + './gradlew "${tasks[@]}" --no-daemon --stacktrace', + "The provider core AAR is missing", + "artifacts=(openiap/build/outputs/aar/*.aar openiap/build/libs/*.jar core/build/outputs/aar/*.aar ../conformance/android/build/outputs/aar/*.aar)", "No Google release artifacts found", 'cp "${artifacts[@]}" release-artifacts/', "Checkout release tag (current version)", @@ -5719,11 +5926,13 @@ function checkFrameworkDependencyHygiene() { /^\s*git checkout\b/, packageIndex + 1, ); - if (!( - guardIndex >= 0 && - packageIndex === guardIndex + 1 && - checkoutIndex > packageIndex - )) { + if ( + !( + guardIndex >= 0 && + packageIndex === guardIndex + 1 && + checkoutIndex > packageIndex + ) + ) { fail( `${frameworkReleaseWorkflow} must guard and check out the existing tag in one shell block`, ); @@ -6729,9 +6938,10 @@ function checkFrameworkDependencyHygiene() { expectIncludes( "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift", [ - "ErrorCode.userCancelled.rawValue", + "openIap.purchaseErrorListener", + "Variant(error.code.rawValue)", "ErrorCode.developerError.rawValue", - "ErrorCode.syncError.rawValue", + "GodotIapHelper.errorCode(error, fallback: .syncError)", ], "Godot iOS purchase errors must emit OpenIAP error codes", ); @@ -7380,7 +7590,10 @@ function checkFrameworkDependencyHygiene() { ); expectIncludes( `${wrapper}/build.gradle`, - ["apply from: project.file('openiap-store.gradle')", "openIapResolveStore("], + [ + "apply from: project.file('openiap-store.gradle')", + "openIapResolveStore(", + ], "Android wrappers must select the store through openiap-store.gradle", ); expectNotIncludes( @@ -7394,52 +7607,74 @@ function checkFrameworkDependencyHygiene() { "Android wrappers must read the store only through openiap-store.gradle", ); } - // Five store alias tables (Groovy, JS, Kotlin, GDScript, MSBuild) cannot + // Six store alias tables (Groovy, JS, Kotlin, GDScript, MSBuild, TS) cannot // share code, so they must match entry for entry. const aliasTables = { "packages/google/gradle/openiap-store.gradle": (text) => { - const block = /ext\.openIapStoreAliases = \[([\s\S]*?)\]/.exec(text)?.[1]; + const block = /ext\.openIapStoreAliases = \[([\s\S]*?)\]/.exec( + text, + )?.[1]; return block - ? [...block.matchAll(/'?([A-Za-z0-9._-]+)'?\s*:\s*'([a-z]+)'/g)].map( - (one) => [one[1], one[2]], - ) + ? [ + ...block.matchAll( + /'?([A-Za-z0-9._-]+)'?\s*:\s*'([a-z][a-z0-9._-]*)'/g, + ), + ].map((one) => [one[1], one[2]]) : null; }, "packages/cli/src/checks.mjs": (text) => { const block = /const STORE_ALIASES = \{([\s\S]*?)\n\};/.exec(text)?.[1]; return block - ? [...block.matchAll(/"?([A-Za-z0-9._-]+)"?\s*:\s*"([a-z]+)"/g)].map( - (one) => [one[1], one[2]], - ) + ? [ + ...block.matchAll( + /"?([A-Za-z0-9._-]+)"?\s*:\s*"([a-z][a-z0-9._-]*)"/g, + ), + ].map((one) => [one[1], one[2]]) : null; }, - "packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt": ( - text, - ) => { - const block = /private val storeAliases = mapOf\(([\s\S]*?)\n\)/.exec( - text, - )?.[1]; + "libraries/expo-iap/plugin/src/withIAP.ts": (text) => { + const block = + /const ANDROID_STORE_ALIASES: Record = \{([\s\S]*?)\n\};/.exec( + text, + )?.[1]; return block ? [ ...block.matchAll( - /"([A-Za-z0-9._-]+)"\s*to\s*"([a-z]+)"/g, + /"?([A-Za-z0-9._-]+)"?\s*:\s*"([a-z][a-z0-9._-]*)"/g, ), ].map((one) => [one[1], one[2]]) : null; }, + "packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt": + (text) => { + const block = /private val storeAliases = mapOf\(([\s\S]*?)\n\)/.exec( + text, + )?.[1]; + return block + ? [ + ...block.matchAll( + /"([A-Za-z0-9._-]+)"\s*to\s*"([a-z][a-z0-9._-]*)"/g, + ), + ].map((one) => [one[1], one[2]]) + : null; + }, "libraries/godot-iap/addons/godot-iap/android_store.gd": (text) => { const block = /const ALIASES := \{([\s\S]*?)\n\}/.exec(text)?.[1]; return block - ? [...block.matchAll(/"([A-Za-z0-9._-]+)"\s*:\s*"([a-z]+)"/g)].map((one) => [ - one[1], - one[2], - ]) + ? [ + ...block.matchAll( + /"([A-Za-z0-9._-]+)"\s*:\s*"([a-z][a-z0-9._-]*)"/g, + ), + ].map((one) => [one[1], one[2]]) : null; }, - "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets": (text) => - [...text.matchAll(/<_OpenIapStoreName Include="([^"]+)" Store="([a-z]+)"/g)].flatMap( - (one) => one[1].split(";").map((alias) => [alias, one[2]]), - ), + "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets": + (text) => + [ + ...text.matchAll( + /<_OpenIapStoreName Include="([^"]+)" Store="([a-z][a-z0-9._-]*)"/g, + ), + ].flatMap((one) => one[1].split(";").map((alias) => [alias, one[2]])), }; const parsedAliases = {}; for (const [file, parse] of Object.entries(aliasTables)) { @@ -7454,18 +7689,31 @@ function checkFrameworkDependencyHygiene() { } // Godot and MAUI have no opt-out build, so `none` is the one id they may omit. const godotFile = "libraries/godot-iap/addons/godot-iap/android_store.gd"; - const mauiFile = "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets"; + const mauiFile = + "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets"; // The facade maps aliases onto the three ids; the ids and the opt-out are // not keys there, and it has no device so `auto` never reaches it. const facadeFile = "packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt"; - const facadeSkips = new Set(["play", "horizon", "amazon", "auto", "none"]); - const reference = parsedAliases["packages/google/gradle/openiap-store.gradle"]; + const facadeSkips = new Set([ + "auto", + "none", + ...JSON.parse(read("specs/client/src/store-registry.json")) + .stores.filter((store) => + storeBindings(store).some( + (binding) => binding.platform === "android", + ), + ) + .map((store) => store.id), + ]); + const reference = + parsedAliases["packages/google/gradle/openiap-store.gradle"]; if (reference) { for (const [file, table] of Object.entries(parsedAliases)) { if (file === "packages/google/gradle/openiap-store.gradle") continue; for (const [alias, store] of reference) { - if ((file === godotFile || file === mauiFile) && store === "none") continue; + if ((file === godotFile || file === mauiFile) && store === "none") + continue; if (file === facadeFile && facadeSkips.has(alias)) continue; const mine = table.get(alias); if (mine === undefined) { @@ -7478,7 +7726,9 @@ function checkFrameworkDependencyHygiene() { } for (const alias of table.keys()) { if (!reference.has(alias)) { - fail(`${file}: store alias ${JSON.stringify(alias)} is not in the resolver`); + fail( + `${file}: store alias ${JSON.stringify(alias)} is not in the resolver`, + ); } } } @@ -7503,13 +7753,20 @@ function checkFrameworkDependencyHygiene() { } } } - // MAUI's unknown-store error lists every value its table accepts. + // MAUI lists official aliases and explains how to select community providers. if (reference && exists(mauiFile)) { - const hint = /is not a store\. Use ([a-z, ]+), or ([a-z]+) \(aliases: ([a-z/, -]+)\)\./.exec( - read(mauiFile), - ); - const listed = hint ? [...hint[1].split(", "), hint[2], ...hint[3].split(/, |\//)] : []; - const accepted = [...reference].filter(([, store]) => store !== "none").map(([alias]) => alias); + const hint = + /is not a store\. Use ([a-z, ]+), or ([a-z]+) \(aliases: ([a-z/, -]+)\)\./.exec( + read(mauiFile), + ); + const listed = hint + ? [...hint[1].split(", "), hint[2], ...hint[3].split(/, |\//)] + : []; + const accepted = [...reference] + .filter(([, store]) => + ["auto", "play", "horizon", "amazon"].includes(store), + ) + .map(([alias]) => alias); if (listed.sort().join() !== accepted.sort().join()) { fail( `${mauiFile}: the unknown-store error lists [${listed.join(", ")}], not [${accepted.join(", ")}]`, @@ -8284,7 +8541,9 @@ function checkFrameworkDependencyHygiene() { const mauiProps = read("libraries/maui-iap/src/Directory.Build.props"); // The app build reads the store SDK versions from the package itself. - const mauiStoreProps = read("libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props"); + const mauiStoreProps = read( + "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props", + ); const mauiBillingVersion = mauiStoreProps.match( /([^<]+)<\/MauiPlayBillingVersion>/, )?.[1]; @@ -8309,7 +8568,10 @@ function checkFrameworkDependencyHygiene() { "MauiPlayServicesLocationVersion", "MauiPlayServicesTasksVersion", "MauiDataTransportVersion", - ].map((name) => [name, mauiProps.match(new RegExp(`<${name}>([^<]+)`))?.[1]]); + ].map((name) => [ + name, + mauiProps.match(new RegExp(`<${name}>([^<]+)`))?.[1], + ]); const mauiGoogleGsonNuGetVersion = mauiProps.match( /([^<]+)<\/MauiGoogleGsonNuGetVersion>/, )?.[1]; @@ -8481,8 +8743,12 @@ function checkFrameworkDependencyHygiene() { // Billing's POM dependencies are exempted from verification by exact // version, so a Billing bump has to revisit that list. if ( - exists("libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets") && - !read("libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets").includes(`billing:${googleBillingVersions[0]}'s POM dependencies`) + exists( + "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets", + ) && + !read( + "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets", + ).includes(`billing:${googleBillingVersions[0]}'s POM dependencies`) ) { fail( `libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets: the Billing dependency exemptions must be revisited for billing ${googleBillingVersions[0]}`, @@ -8685,7 +8951,11 @@ function checkFrameworkDependencyHygiene() { ); expectNotIncludes( "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", - ["Xamarin.Android.Google.BillingClient", "OpenIapGoogleAarFlavor", "AndroidMavenLibrary"], + [ + "Xamarin.Android.Google.BillingClient", + "OpenIapGoogleAarFlavor", + "AndroidMavenLibrary", + ], "MAUI package must not link Billing through its NuGet binding, whose Java wrappers need it in every store's build", ); expectIncludes( @@ -9381,7 +9651,7 @@ function checkXcode27StoreKitCoverage() { "Xcode 27 StoreKit native mapping", ); expectIncludes( - "packages/apple/Sources/OpenIapModule.swift", + "packages/apple/Sources/OpenIapStoreKitModule.swift", [ "AppStore.presentOfferCodeRedeemSheet(", "StoreKitTypesBridge.isAutoRenewingSubscriptionProductType", diff --git a/scripts/audit-purchase-payload-parity.mjs b/scripts/audit-purchase-payload-parity.mjs index c99a08e18..ec457c236 100644 --- a/scripts/audit-purchase-payload-parity.mjs +++ b/scripts/audit-purchase-payload-parity.mjs @@ -51,6 +51,13 @@ function expectNotIncludes(relativePath, needles, label = relativePath) { } } +function expectMatch(relativePath, pattern, label = relativePath) { + expectFile(relativePath); + if (exists(relativePath) && !pattern.test(read(relativePath))) { + fail(`${label} must match ${pattern}`); + } +} + function expectSameSet(label, expected, actual) { const expectedSet = new Set(expected); const actualSet = new Set(actual); @@ -1330,18 +1337,36 @@ function checkStrictAppleFrameworkQuerySerialization() { expectIncludes( "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift", [ - "// OpenIAP requestPurchase emits its canonical error exactly once", - "code: ErrorCode.purchaseError.rawValue,\n message: error.localizedDescription,\n productId: productId", + "// Providers own purchase-error delivery after dispatch.", + 'GodotIapLog.failure("requestPurchaseWithPayload", error: error)', + "enqueueLifecycleOperation(invalidateConnection: false)", + "if disconnected {\n let lifecycle = self.advanceConnectionGeneration()", ], - "Godot iOS request-purchase terminal error delivery", + "Godot iOS provider error ownership and failure-aware teardown", ); + const godotAppleSource = read( + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift", + ); + const godotPurchaseDispatch = godotAppleSource + .split( + "public func requestPurchaseWithPayload(argsJson: String) -> String {", + )[1] + ?.split("public func finishTransaction(argsJson: String) -> String {")[0]; + if ( + !godotPurchaseDispatch || + godotPurchaseDispatch.includes("emitPurchaseError(") + ) { + fail( + "Godot iOS must leave post-dispatch purchase-error events to the provider", + ); + } expectIncludes( "libraries/godot-iap/addons/godot-iap/godot_iap.gd", [ 'if result.get("status", "") == "pending" or result.get("pending", false):\n\t\treturn null', 'const APPLE_PLATFORMS := ["iOS", "macOS"]', "func _is_apple() -> bool:\n\treturn _platform in APPLE_PLATFORMS", - 'if _is_apple() and store != "apple":', + 'if _is_apple() and store not in ["apple", "unknown"]:', 'store not in ["google", "amazon", "horizon"]', ], "Godot pending dispatch and platform-scoped purchase decoding", @@ -1349,10 +1374,18 @@ function checkStrictAppleFrameworkQuerySerialization() { expectIncludes( "libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs", [ - "ios.Store is not IapStore.Apple", - "android.Store != IapStore.Google", - "android.Store != IapStore.Amazon", - "android.Store != IapStore.Horizon", + "!ValidIOSStore(ios)", + "IapStore.Apple => purchase.StoreId == StoreIds.Apple", + "!ValidAndroidStore(android)", + "IapStore.Google => purchase.StoreId == StoreIds.Play", + "IapStore.Horizon => purchase.StoreId == StoreIds.Horizon", + "IapStore.Amazon => purchase.StoreId == StoreIds.Amazon", + "IapStore.Unknown => ValidCommunityStoreId(purchase.StoreId)", + 'Regex.IsMatch(id, "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")', + 'id is not ("auto" or "none" or "unknown" or "google")', + "id != StoreIds.Apple && id != StoreIds.Play", + "id != StoreIds.Horizon && id != StoreIds.Amazon", + "_ => false", ], "MAUI platform-scoped authoritative purchase-list decoding", ); @@ -1427,6 +1460,7 @@ function checkReactNativePurchasePayloadContracts() { const legacyTransportFields = [ "purchaseStateAndroid", "purchaseTokenAndroid", + "platform", ]; expectSameSet( "React Native NitroPurchase transport fields", @@ -1466,6 +1500,9 @@ function checkReactNativePurchasePayloadContracts() { ); } const allowedSources = new Set([field]); + if (field === "storeId") { + allowedSources.add("store"); + } if (field === "purchaseState") { allowedSources.add("purchaseStateAndroid"); } @@ -1841,8 +1878,7 @@ function checkGooglePurchasePayloadContracts() { productId: /^productsList\.firstOrNull\(\)\.orEmpty\(\)$/, purchaseToken: /^token$/, signatureAndroid: /^signature$/, - transactionId: - /^orderId\?\.takeIf \{ it\.isNotBlank\(\) \} \?: token$/, + transactionId: /^orderId\?\.takeIf \{ it\.isNotBlank\(\) \} \?: token$/, }, intentionallyDefaultedFields: [ "isSuspendedAndroid", @@ -2140,7 +2176,7 @@ function checkPurchaseRoundTripRegressionCoverage() { function checkActiveSubscriptionFailureContracts() { expectIncludes( - "packages/apple/Sources/OpenIapModule.swift", + "packages/apple/Sources/OpenIapStoreKitModule.swift", [ "for await verification in Transaction.currentEntitlements {\n let transaction = try checkVerified(verification)", ], @@ -2192,9 +2228,7 @@ function checkActiveSubscriptionFailureContracts() { ); expectIncludes( "libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs", - [ - 'operation: "getActiveSubscriptions"', - ], + ['operation: "getActiveSubscriptions"'], "MAUI active-subscription list decoding", ); expectIncludes( @@ -2220,10 +2254,14 @@ function checkActiveSubscriptionFailureContracts() { [ "Unexpected active-subscription response type:", "Native active-subscription response contained malformed fields", - "return activeSubscriptions.isNotEmpty;", ], "Flutter active-subscription failure propagation", ); + expectMatch( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + /_channel\.invokeMethod\(\s*'hasActiveSubscriptions'/, + "Flutter delegates the subscription boolean to the provider", + ); expectNotIncludes( "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", ["If there's an error getting subscriptions, return false"], diff --git a/scripts/audit-release-notes.test.mjs b/scripts/audit-release-notes.test.mjs index a6fe4e14d..923fe9688 100644 --- a/scripts/audit-release-notes.test.mjs +++ b/scripts/audit-release-notes.test.mjs @@ -119,3 +119,487 @@ test("the audit reads only the branch's own changes after the base moved on", () fs.rmSync(root, { recursive: true, force: true }); } }); + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function normalize(text) { + // JSX drops whitespace around newlines, so `word\n;` renders "word;". + // Same-line spaces survive, so a rendered "storeId ;" must fail. + // Versions drift after release, so needles pin words, not digits. + return text + .replaceAll("'", "'") + .replaceAll("{' '}", " ") + .replace(/\d+\.\d+\.\d+/g, "0.0.0") + .replace(/[ \t]*\n\s*([;,.!?)%])/g, "$1") + .replaceAll(/\s+/g, " "); +} + +function cardSource() { + const source = fs.readFileSync(path.join(repoRoot, RELEASE_NOTES), "utf8"); + const start = source.indexOf("id: 'community-store-providers-2026-10-02'"); + const end = source.indexOf("id: '", start + 1); + return source.slice(start, end === -1 ? undefined : end); +} + +function cardText() { + return normalize(cardSource()); +} + +// Only the Breaking list: a bullet moved to Common changes must fail. +function breakingSlice() { + const card = cardSource(); + const start = card.indexOf("
    Breaking changes
    "); + const end = card.indexOf("
    Common changes
    ", start); + assert.ok(start !== -1, "card is missing the Breaking changes heading"); + assert.ok(end !== -1, "card is missing the Common changes heading"); + assert.ok(start < end, "Breaking changes must come before Common changes"); + return normalize(card.slice(start, end)); +} + +// Plain text of a JSX fragment. A depth counter drops nested or partial tags, +// which a single regex pass would leave behind. +function stripTags(text) { + let depth = 0; + let plain = ""; + for (const char of text) { + if (char === "<") depth++; + else if (char === ">" && depth > 0) depth--; + else if (depth === 0) plain += char; + } + return plain; +} + +function breakingBullets() { + const slice = breakingSlice(); + return [...slice.matchAll(/
  • ([\s\S]*?)<\/li>/g)].map((match) => + normalize(stripTags(match[1])).trim(), + ); +} + +function migrationSource() { + return fs.readFileSync( + path.join(repoRoot, "packages/docs/src/pages/docs/updates/migration.tsx"), + "utf8", + ); +} + +// Only the provider-contract train: shared needles must live in this section. +function migrationSlice() { + const source = migrationSource(); + const start = source.lastIndexOf( + "storeId required on purchases and IAPKit results; set it on hand-built values (C# and the Kotlin IAPKit result infer official ids).", + "KMP 4.0.0 adds UNKNOWN to the public Store enum; add the branch to every exhaustive when (getStore()).", + "OpenIAP Google 4.0.0 and KMP 4.0.0 make the Kotlin RequestVerifyPurchaseWithIapkitResult a plain class whose constructor and copy carry storeId; recompile code that copies results or constructs or copies purchases.", + "OpenIAP Google 4.0.0 moves shared classes into the openiap-core artifact; apps linking the AAR by file must add it (Maven consumers get it transitively).", + "OpenIAP Apple 4.0.0 requires tvOS 16.0+ (SwiftPM; CocoaPods already required 16.0); raise the deployment target of tvOS apps.", + "OpenIAP Apple 4.0.0 throws CancellationError, with no purchase-error event, when the calling task of requestPurchase or another StoreKit operation is cancelled; catch it at the call site.", + "Flutter 11.0.0 initConnection() now returns false on iOS and macOS when StoreKit cannot make payments; it returned true before.", + "React Native 17.0.0, Expo 6.0.0, Flutter 11.0.0, and Godot 4.0.0 forward the provider's Android error code where they returned fixed codes; handle specific codes such as not-prepared.", + "Flutter 11.0.0 and Godot 4.0.0 (and KMP 4.0.0 on iOS) report the native error code from failed verification instead of always purchase-verification-failed; match specific codes.", + "React Native 17.0.0, Expo 6.0.0, and Flutter 11.0.0 run the provider's Android restore first, and on Horizon deliver each owned purchase to purchase listeners; make the handler idempotent.", + "React Native 17.0.0 and Flutter 11.0.0 (every call), KMP 4.0.0 and MAUI 3.0.0 (id-filtered calls) answer hasActiveSubscriptions on iOS from the active flag; a subscriber in billing grace reads inactive.", + "Godot 4.0.0 emits one purchase_error, not two, for a failed Apple restore, and one for a failed verify_purchase (on Android only when the result carries a code).", + "Godot 4.0.0 reports OpenIAP's error code in failed Apple products_fetched payloads for get_storefront, fetch_products, and iOS-only calls, which carried service-error or no code; match specific codes.", + "Godot 4.0.0 fails the Android export when openiap/android_store is unrecognized instead of falling back to Play; fix the store value.", +]; + +test("the community provider card carries an explicit Breaking changes section", () => { + const card = cardText(); + const breaking = breakingSlice(); + assert.ok( + card.includes("/docs/updates/migration#provider-contract-upgrade"), + "card is missing the migration guide link", + ); + for (const bullet of BREAKING_BULLETS) { + assert.ok( + breaking.includes(normalize(bullet)), + `Breaking list is missing: ${bullet}`, + ); + } + for (const moved of [ + "Purchases and verification outputs include a required", + "Add storeId to manually constructed", + "Requires tvOS 16.0+.", + "Cancelling a requestPurchase task throws", + "instead of fixed codes", + "also report a store-aware init message", + "run the provider's Android restore before querying ownership", + "On Horizon, each owned purchase then reaches", + "now answer hasActiveSubscriptions", + "needs a new branch", + "Client Protocol 0.2.0 and every SDK require", + "data class into", + "operations that rethrow it", + "Each item names who is affected", + "report the provider's own Android error code", + "Rejects invalid Android provider selection during export", + "emits one error for Apple restore failures", + "which must also cover", + "constructs or copies purchases or results", + "must add it, Maven", + "when the task calling", + "initConnection, or another", + "match generated", + "KMP 4.0.0, and Godot 4.0.0 report", + "emits a single", + "instead of staying silent", + "for before and after code", + "store-aware init message", + ]) { + assert.ok( + !card.toLowerCase().includes(normalize(moved).toLowerCase()), + `moved fact is duplicated outside Breaking changes: ${moved}`, + ); + } + assert.ok( + !card.includes("still signals each owned purchase once"), + "stale Godot restore claim is still present", + ); + assert.ok( + !card.includes("StoreKit's active flag"), + "stale StoreKit flag claim is still present", + ); +}); + +test("every Breaking bullet is one sentence of at most 30 words", () => { + const bullets = breakingBullets(); + assert.equal(bullets.length, BREAKING_BULLETS.length); + for (const bullet of bullets) { + const words = bullet.split(/\s+/).filter(Boolean); + assert.ok( + words.length <= 30, + `bullet has ${words.length} words (at most 30): ${bullet}`, + ); + const sentences = bullet + .replace(/\d+\.\d+(?:\.\d+)?/g, "V") + .split("") + .filter((char) => char === "." || char === "!" || char === "?").length; + assert.equal(sentences, 1, `bullet is not one sentence: ${bullet}`); + } +}); + +test("the migration guide covers the provider contract upgrade", () => { + const slice = migrationSlice(); + const page = normalize(slice); + for (const anchor of [ + 'id="provider-contract-upgrade"', + 'id="provider-contract-store-id"', + 'id="provider-contract-kmp-store"', + 'id="provider-contract-kotlin-result"', + 'id="provider-contract-openiap-core"', + 'id="provider-contract-tvos"', + 'id="provider-contract-cancellation"', + 'id="provider-contract-flutter-init"', + 'id="provider-contract-error-codes"', + 'id="provider-contract-verify-codes"', + 'id="provider-contract-restore"', + 'id="provider-contract-godot-events"', + 'id="provider-contract-godot-export"', + 'id="provider-contract-subscription-flag"', + ]) { + assert.ok( + slice.includes(`", + "Store.NONE", + "preload(", + "its own provider id", + "IapStore.unknown", + "from_dict returns null", + "JsonException", + "storefrontCountryCodeIOS", + "the new openiap-core", + "recompile", + "component1-3", + "tvOS 16", + "CancellationError", + "can be cancelled, such as a SwiftUI", + "user-cancelled", + "wrapped the cancellation into an emitted", + "threw it as a PurchaseError without an event", + "without updating state", + "even after connecting", + "cannot make payments", + "not-prepared", + "rejected every failure with", + "answered native failures with", + "Dart-guarded calls still throw", + "StoreConnectionFailure", + "verification without a userId", + "transaction-validation-failed", + "expo-iap", + "loadPurchases", + "Failed to restore purchases [code]", + "Failed to sync iOS purchases [code]", + "grantOnce(purchase.purchaseToken", + "get_available_purchases_result", + "verify_purchase now emits", + "get_storefront failures,", + "failure payloads for", + "failure results of the iOS-only methods now carry", + "now carry OpenIAP's code instead of", + "or no code", + "openiap/android_store", + "no longer checks init in Dart", + "connect and answer instead of throwing", + "Play and Horizon report the provider's", + "or call it before", + "Failed to check active subscriptions [code]", + "Failed to get active subscriptions [code]", + "Flutter on iOS and macOS", + "Godot on iOS and Android", + "MAUI also on Mac", + "gracePeriodExpirationDate", + "/docs/updates/releases#community-store-providers-2026-10-02", + ]) { + assert.ok(page.includes(substance), `migration guide is missing: ${substance}`); + } + for (const dropped of [ + "the cancellation was wrapped into a", + "endConnection runs first", + "calls before init now report", + "native-only calls now report", + "failures and async failure payloads in", + "KMP and MAUI also treat an empty id list", + "pass an explicit empty id list", + "pass explicit ids or omit the filter", + "the other operations that rethrow it", + "const finished = ", + "Android failures arrived as one fixed code", + "store: .google", + "with its explicit provider id", + "ToString", + "which is never returned", + "add the branches", + "match the provider's codes against", + "init errors keep the", + "On Amazon a failed init", + "Store purchase restore did not complete", + "Onside purchase restore did not complete", + "AppStore.sync", + "empty id list means all", + "const granted", + "failures now carry OpenIAP's code instead of always", + "now carry a code where they had none", + ]) { + assert.ok(!page.includes(dropped), `removed claim is still present: ${dropped}`); + } +}); + +test("card and migration links resolve to existing anchors", () => { + const migration = migrationSource(); + assert.ok( + migration.includes('id="provider-contract-upgrade"'), + "card link target is missing: provider-contract-upgrade", + ); + const releases = fs.readFileSync(path.join(repoRoot, RELEASE_NOTES), "utf8"); + assert.ok( + releases.includes('id="community-store-providers-2026-10-02"'), + "migration link target is missing: community-store-providers-2026-10-02", + ); + const providers = fs.readFileSync( + path.join(repoRoot, "packages/docs/src/pages/docs/guides/store-providers.tsx"), + "utf8", + ); + assert.ok( + providers.includes('id="identity"'), + "migration link target is missing: store-providers identity", + ); +}); + +function docsPages(dir) { + const found = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) found.push(...docsPages(full)); + else if (entry.name.endsWith(".tsx")) found.push(full); + } + return found.sort(); +} + +test("every migration and release deep link resolves to an existing id", () => { + const migration = migrationSource(); + const releases = fs.readFileSync(path.join(repoRoot, RELEASE_NOTES), "utf8"); + const links = []; + for (const page of docsPages(path.join(repoRoot, "packages/docs/src"))) { + const source = fs.readFileSync(page, "utf8"); + for (const match of source.matchAll( + /\/docs\/updates\/(migration|releases)#([A-Za-z0-9_-]+)/g, + )) { + links.push({ page: path.relative(repoRoot, page), target: match[1], id: match[2] }); + } + } + assert.ok(links.length > 0, "no migration or release deep links found"); + for (const { page, target, id } of links) { + const source = target === "migration" ? migration : releases; + assert.ok( + source.includes(`id="${id}"`), + `${page} links to missing ${target}#${id}`, + ); + } +}); + +test("the has-active-subscriptions page states the iOS active flag and grace", () => { + const source = fs.readFileSync( + path.join(repoRoot, "packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx"), + "utf8", + ); + const page = normalize(source); + assert.ok( + page.includes("has the active flag set (expiration in the future); a subscriber in billing grace reads inactive"), + "page is missing the iOS active-flag and grace statement", + ); + assert.ok( + page.includes("/docs/updates/migration#provider-contract-subscription-flag"), + "page is missing the migration guide link", + ); + assert.ok( + !page.includes("the iterator yields at least one non-expired subscription"), + "stale iterator claim is still present", + ); +}); + +test("the restore-purchases page documents the provider-first Android restore", () => { + const source = fs.readFileSync( + path.join(repoRoot, "packages/docs/src/pages/docs/apis/restore-purchases.tsx"), + "utf8", + ); + const page = normalize(source); + assert.ok(page.includes("Reads owned purchases from the store")); + assert.ok(page.includes("with no system-level UI prompt")); + assert.ok( + page.includes( + "React Native, Expo, Flutter, MAUI, Godot, and KMP (Amazon, Horizon, and community-provider builds) call the provider's restore", + ), + ); + assert.ok(page.includes("KMP's Play build queries ownership directly")); + assert.ok(page.includes("PurchasingService.getPurchaseUpdates")); + assert.ok( + page.includes("Play and Amazon restores deliver nothing by themselves"), + ); + assert.ok( + page.includes( + "Horizon's restore delivers each owned purchase to the purchase listeners (React Native, Expo, Flutter, MAUI, and KMP)", + ), + ); + assert.ok( + page.includes( + "Godot emits no purchase_updated signal; read owned purchases with get_available_purchases_result", + ), + ); + assert.ok( + !page.includes("signals each owned purchase once"), + "stale Godot restore claim is still present", + ); + assert.ok( + !page.includes("Runs the provider's restore first"), + "unqualified restore claim is still present", + ); + assert.ok( + !page.includes("Play has no concept of an explicit"), + "stale restore claim is still present", + ); +}); + +test("the get-active-subscriptions page documents the platform isActive rules", () => { + const source = fs.readFileSync( + path.join(repoRoot, "packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx"), + "utf8", + ); + const page = normalize(source); + assert.ok(page.includes("while the expiration date is in the future")); + assert.ok(page.includes("billing grace reads inactive")); + assert.ok(page.includes("while the purchase state is purchased")); + assert.ok(page.includes("purchaseState == PURCHASED as active")); + assert.ok( + !page.includes("paying or grace"), + "stale isActive claim is still present", + ); + assert.ok( + !page.includes("autoRenewing as active"), + "stale autoRenewing claim is still present", + ); +}); + +test("the registry note keeps a major's profile while any entry cites it", () => { + const guide = fs.readFileSync( + path.join(repoRoot, "knowledge/internal/04-platform-packages.md"), + "utf8", + ); + assert.ok(guide.includes("for as long as any registry entry cites it")); + assert.ok( + !guide.includes("until the registry maintenance window ends"), + "stale registry window claim is still present", + ); + const profile = fs.readFileSync( + path.join(repoRoot, "packages/conformance/src/spec/provider-profile.mjs"), + "utf8", + ); + assert.ok(profile.includes("while any registry entry cites it")); + assert.ok( + !profile.includes("until the registry window ends"), + "stale registry window claim is still present", + ); +}); + +test("the Client Protocol feature-PR rule refreshes the lockfile", () => { + for (const file of [ + "knowledge/internal/04-platform-packages.md", + "knowledge/internal/06-git-deployment.md", + ".claude/commands/release.md", + ]) { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + assert.ok( + source.includes("bun install --lockfile-only --ignore-scripts"), + `${file} is missing the lockfile refresh`, + ); + } + const guide = fs.readFileSync( + path.join(repoRoot, "knowledge/internal/04-platform-packages.md"), + "utf8", + ); + assert.ok( + guide.includes("refresh `bun.lock` and run"), + "the Client Protocol update steps skip the lockfile refresh", + ); + assert.ok( + !guide.includes("mirrors it into `openiap-versions.json`"), + "stale sync-mirror claim is still present", + ); +}); + +test("the Expo restore JSDoc names the provider restore first", () => { + const source = fs.readFileSync( + path.join(repoRoot, "libraries/expo-iap/src/index.ts"), + "utf8", + ); + assert.ok( + source.includes( + "Android: run the provider's restore, then fetch available purchases", + ), + ); + assert.ok( + !source.includes("the query itself restores them"), + "stale restore JSDoc is still present", + ); +}); diff --git a/scripts/audit-release-sync-script.mjs b/scripts/audit-release-sync-script.mjs index 54a032fb0..af8e331a3 100644 --- a/scripts/audit-release-sync-script.mjs +++ b/scripts/audit-release-sync-script.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node -// Every release lane calls sync-release-generated.sh and nothing else runs it, -// so a dropped line continuation in its `git add` shipped once and only -// surfaced mid-release: bash ended the command early and ran the next path as -// a command (exit 126, "is a directory"). Neither `bash -n` nor shellcheck +// Release lanes run sync-release-generated.sh, and a Client Protocol feature +// PR runs it by hand. A dropped line continuation in its `git add` once +// shipped and surfaced only mid-release: bash ended the command early and ran +// the next path as a command (exit 126, "is a directory"). Neither `bash -n` +// nor shellcheck // flags that — an orphaned path is a syntactically valid command — so this // audit reproduces bash's own continuation rules and then asserts that no // logical command starts with a repository path. diff --git a/scripts/audit-release-sync-script.test.mjs b/scripts/audit-release-sync-script.test.mjs index 4015cb062..dfdf2aab5 100644 --- a/scripts/audit-release-sync-script.test.mjs +++ b/scripts/audit-release-sync-script.test.mjs @@ -1,6 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -101,3 +110,67 @@ test("the committed script passes its own audit", () => { ); assert.deepEqual(auditGitAddBlocks(source), []); }); + +test("a Google version bump stages MAUI's regenerated core version", (t) => { + const scratch = mkdtempSync(join(tmpdir(), "openiap-release-staging-")); + t.after(() => rmSync(scratch, { recursive: true, force: true })); + const stage = toLogicalLines( + readFileSync(join(REPO_ROOT, "scripts/sync-release-generated.sh"), "utf8"), + ) + .find((line) => line.text.trim().startsWith("git add ")) + .text.trim() + .split(/\s+/u) + .slice(2); + for (const path of stage) { + mkdirSync(dirname(join(scratch, path)), { recursive: true }); + writeFileSync(join(scratch, path), ""); + } + const props = + "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props"; + const dependencies = "libraries/maui-iap/src/Directory.Build.props"; + mkdirSync(dirname(join(scratch, dependencies)), { recursive: true }); + for (const path of [props, dependencies, "openiap-versions.json"]) { + copyFileSync(join(REPO_ROOT, path), join(scratch, path)); + } + const git = (...args) => + execFileSync("git", args, { cwd: scratch, encoding: "utf8" }); + git("init", "-q"); + git("add", "."); + const baseline = git("write-tree").trim(); + const versions = JSON.parse( + readFileSync(join(scratch, "openiap-versions.json"), "utf8"), + ); + versions.google = "4.0.0"; + writeFileSync( + join(scratch, "openiap-versions.json"), + JSON.stringify(versions), + ); + const sync = readFileSync( + join(REPO_ROOT, "scripts/sync-versions.sh"), + "utf8", + ); + const marker = ` python3 - "$maui_props" "$maui_store_props"`; + const start = sync.indexOf("\n", sync.indexOf(marker)) + 1; + const end = sync.indexOf("\nPY\n", start); + assert.ok(start > 0 && end > start); + execFileSync( + "python3", + ["-", dependencies, props, "8.3.0", "3.0.8", "8.1.2", "66.0.0", "2.14.0"], + { + cwd: scratch, + input: sync.slice(start, end), + }, + ); + git("add", ...stage); + assert.ok( + git("diff", "--cached", baseline, "--name-only") + .trim() + .split("\n") + .includes(props), + ); + assert.match( + git("show", `:${props}`), + /4\.0\.0<\/MauiOpenIapCoreVersion>/u, + ); + assert.equal(git("diff", "--name-only", "--", props).trim(), ""); +}); diff --git a/scripts/audit-security.mjs b/scripts/audit-security.mjs index 6f9626b69..c5bd5d08e 100644 --- a/scripts/audit-security.mjs +++ b/scripts/audit-security.mjs @@ -72,10 +72,25 @@ export function listWorkflowFiles( directory = resolve(repoRoot, ".github/workflows"), prefix = ".github/workflows", ) { - return readdirSync(directory, { withFileTypes: true }) - .filter((entry) => entry.isFile() && /\.ya?ml$/iu.test(entry.name)) - .map((entry) => `${prefix}/${entry.name}`) - .sort(); + const files = readdirSync(directory, { withFileTypes: true }).flatMap( + (entry) => + entry.isDirectory() + ? listWorkflowFiles( + resolve(directory, entry.name), + `${prefix}/${entry.name}`, + ) + : /\.ya?ml$/iu.test(entry.name) + ? [`${prefix}/${entry.name}`] + : [], + ); + const actions = resolve(repoRoot, ".github/actions"); + if ( + directory === resolve(repoRoot, ".github/workflows") && + existsSync(actions) + ) { + files.push(...listWorkflowFiles(actions, ".github/actions")); + } + return files.sort(); } export function findUnpinnedDockerBases(source) { @@ -117,9 +132,14 @@ export function findWorkflowDependencyFindings( } catch (error) { return [`${filename}: invalid YAML (${error.message})`]; } - if (!Object.hasOwn(workflow ?? {}, "permissions")) { + const isAction = + filename.startsWith(".github/actions/") && + /\/action\.ya?ml$/u.test(filename); + if (isAction && Object.hasOwn(workflow ?? {}, "permissions")) { + findings.push(`${filename}: actions inherit permissions from their caller`); + } else if (!isAction && !Object.hasOwn(workflow ?? {}, "permissions")) { findings.push(`${filename}: missing top-level permissions`); - } else { + } else if (!isAction) { const permissions = workflow.permissions; const values = permissions && typeof permissions === "object" @@ -135,7 +155,10 @@ export function findWorkflowDependencyFindings( } } - for (const [jobName, job] of Object.entries(workflow?.jobs ?? {})) { + const jobs = isAction + ? { composite: workflow?.runs } + : (workflow?.jobs ?? {}); + for (const [jobName, job] of Object.entries(jobs)) { if (!Object.hasOwn(job ?? {}, "permissions")) continue; const permissions = job.permissions; if (permissions === "write-all") { @@ -155,7 +178,7 @@ export function findWorkflowDependencyFindings( } } - for (const [jobName, job] of Object.entries(workflow?.jobs ?? {})) { + for (const [jobName, job] of Object.entries(jobs)) { for (const step of job?.steps ?? []) { const action = String(step?.uses ?? "").toLowerCase(); if (!action.startsWith("actions/checkout@")) continue; @@ -265,14 +288,7 @@ export function parseOsvIgnoredVulnerabilities(source) { return ignored; } -// `bun audit` reaches a remote advisory service, and that call fails -// intermittently. The failure is a transport error rather than a verdict, so it -// is retried; anything else, including a real advisory, still fails the audit. -// OSV-Scanner treats `ignoreUntil` as the first day an exception no longer -// applies, so a window dated today is already expired there. This audit used -// `>=`/`<`, which called that same day live — the split that let a lapsed -// exception fail CI while this audit stayed silent. One definition, matching -// the scanner, so the four comparison sites cannot drift again. +// OSV treats ignoreUntil as the first expired day. const isExpired = (ignoreUntil, now) => ignoreUntil <= now.toISOString().slice(0, 10); @@ -287,11 +303,7 @@ const sleepSync = (milliseconds) => { }; export const BUN_AUDIT_ATTEMPTS = 4; -// Exponential rather than linear: the first CI failure this retry was written -// for came back within seconds, but a later one outlasted a 2s/4s window. This -// spans about a minute while costing nothing on the normal path, which -// succeeds on the first attempt and never sleeps. It does not make the audit -// immune to a real outage — that still fails closed, and is re-run. +// Allow advisory-service transport failures a bounded minute to recover. export const BUN_AUDIT_BACKOFF_MS = [5_000, 15_000, 40_000]; export function runBunAudit( @@ -323,6 +335,8 @@ export function auditDependencies( auditOptions = {}, ) { const findings = []; + const ignoredByLock = new Map(); + const usedByLock = new Map(); let ignoredCount = 0; for (const { directory, lockfile } of projects) { const result = runBunAudit(run, resolve(repoRoot, directory), auditOptions); @@ -349,43 +363,32 @@ export function auditDependencies( ? parseOsvIgnoredVulnerabilities(readFileSync(configPath, "utf8")) : new Map(); const used = new Set(); + ignoredByLock.set(lockfile, ignored); + usedByLock.set(lockfile, used); for (const advisory of advisories) { const exception = ignored.get(advisory.id); - if ( - exception && - !isExpired(exception.ignoreUntil, now) - ) { + if (exception && !isExpired(exception.ignoreUntil, now)) { + if (!used.has(advisory.id)) ignoredCount += 1; used.add(advisory.id); - ignoredCount += 1; continue; } findings.push({ ...advisory, lockfile }); } - for (const [id, exception] of ignored) { - if (used.has(id)) continue; - const state = - isExpired(exception.ignoreUntil, now) - ? "expired" - : "unused"; - findings.push({ - id, - lockfile, - packageName: "exception", - severity: state, - title: `${state} dependency exception`, - }); - } if (result.status === 1 && advisories.length === 0) { throw new Error(`${lockfile}: bun audit exited ${result.status}`); } } - const bunLocks = new Set(projects.map(({ lockfile }) => lockfile)); - for (const lockfile of osvLockfiles.filter((path) => !bunLocks.has(path))) { + for (const lockfile of osvLockfiles) { const configPath = resolve(repoRoot, dirname(lockfile), "osv-scanner.toml"); - const ignored = existsSync(configPath) - ? parseOsvIgnoredVulnerabilities(readFileSync(configPath, "utf8")) - : new Map(); + const ignored = + ignoredByLock.get(lockfile) ?? + (existsSync(configPath) + ? parseOsvIgnoredVulnerabilities(readFileSync(configPath, "utf8")) + : new Map()); + const used = usedByLock.get(lockfile) ?? new Set(); + ignoredByLock.set(lockfile, ignored); + usedByLock.set(lockfile, used); const result = run( "osv-scanner", [ @@ -422,7 +425,6 @@ export function auditDependencies( ) { throw new Error(`${lockfile}: invalid OSV-Scanner result structure`); } - const used = new Set(); let vulnerabilityCount = 0; for (const scanResult of report.results) { if (!scanResult || !Array.isArray(scanResult.packages)) { @@ -440,15 +442,22 @@ export function auditDependencies( ].filter(Boolean); const acceptedId = ids.find((id) => { const exception = ignored.get(id); - return ( - exception && !isExpired(exception.ignoreUntil, now) - ); + return exception && !isExpired(exception.ignoreUntil, now); }); if (acceptedId) { + if (!used.has(acceptedId)) ignoredCount += 1; used.add(acceptedId); - ignoredCount += 1; continue; } + if ( + findings.some( + (finding) => + finding.lockfile === lockfile && + finding.packageName === pkg.package?.name && + ids.includes(finding.id), + ) + ) + continue; findings.push({ id: vulnerability.id ?? ids[0] ?? "unknown", lockfile, @@ -465,6 +474,10 @@ export function auditDependencies( if (result.status === 1 && vulnerabilityCount === 0) { throw new Error(`${lockfile}: OSV-Scanner exited 1 without findings`); } + } + + for (const [lockfile, ignored] of ignoredByLock) { + const used = usedByLock.get(lockfile); for (const [id, exception] of ignored) { const expired = isExpired(exception.ignoreUntil, now); if (!expired && used.has(id)) continue; @@ -532,11 +545,13 @@ export function findActionFamilyDrift(sources, exempt = LOCKSTEP_EXEMPT) { continue; } - // Only the two places a workflow can name an action: a job calling a - // reusable workflow, and a step. Visiting every `uses:` key also picked up - // `env: { uses: … }`, which is a variable, not a reference. + // Inspect action references, not variables named uses. const references = []; - for (const job of Object.values(workflow?.jobs ?? {})) { + const jobs = + workflow?.runs?.using === "composite" + ? [workflow.runs] + : Object.values(workflow?.jobs ?? {}); + for (const job of jobs) { if (typeof job?.uses === "string") references.push(job.uses); for (const step of Array.isArray(job?.steps) ? job.steps : []) { if (typeof step?.uses === "string") references.push(step.uses); diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs index 1ea703ad6..cd41fda75 100644 --- a/scripts/audit-security.test.mjs +++ b/scripts/audit-security.test.mjs @@ -1,5 +1,9 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { + chmodSync, + existsSync, + mkdirSync, mkdtempSync, readdirSync, readFileSync, @@ -9,6 +13,8 @@ import { import { tmpdir } from "node:os"; import { resolve } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { parseDocument } from "yaml"; import { BUN_AUDIT_ATTEMPTS, @@ -28,6 +34,130 @@ import { summarizeAdvisories, } from "./audit-security.mjs"; +test("coverage CLI wrapper preserves arguments and bounds stalled uploads", () => { + const source = readFileSync( + new URL("../.github/actions/coverage-report/action.yml", import.meta.url), + "utf8", + ); + const steps = parseDocument(source).toJS().runs.steps; + const wrapper = steps + .find(({ id }) => id === "codecov-cli") + .run.match(/<<'EOF'\n([\s\S]*?)\nEOF/u)?.[1]; + assert.ok(wrapper); + const fixture = mkdtempSync(resolve(tmpdir(), "openiap-coverage-timeout-")); + try { + const executable = resolve(fixture, "codecov"); + writeFileSync(executable, `${wrapper}\n`); + writeFileSync( + `${executable}-cli`, + '#!/usr/bin/env bash\ncase "$1" in --hang) exec sleep 5;; --fail) exit 17;; esac\nprintf "%s\\n" "$@"\n', + ); + const timeout = resolve(fixture, "timeout"); + writeFileSync( + timeout, + '#!/usr/bin/env bash\n[[ "$1" == 120s ]] || exit 99\nshift\nexec "$COVERAGE_TEST_TIMEOUT" 1s "$@"\n', + ); + for (const filename of [executable, `${executable}-cli`, timeout]) + chmodSync(filename, 0o755); + const command = process.platform === "darwin" ? "gtimeout" : "timeout"; + const env = { + ...process.env, + PATH: `${fixture}:${process.env.PATH}`, + COVERAGE_TEST_TIMEOUT: spawnSync( + "bash", + ["-c", `command -v ${command}`], + { encoding: "utf8" }, + ).stdout.trim(), + }; + assert.ok(env.COVERAGE_TEST_TIMEOUT); + const invoke = (args) => + spawnSync(executable, args, { env, encoding: "utf8", timeout: 4000 }); + const success = invoke(["--file", "a file.info", "--flag", "expo-iap"]); + assert.equal(success.status, 0, success.stderr); + assert.equal(success.stdout, "--file\na file.info\n--flag\nexpo-iap\n"); + assert.equal(invoke(["--fail"]).status, 17); + assert.equal(invoke(["--hang"]).status, 124); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +test("verified tool installation rejects failed and corrupted downloads", () => { + const fixture = mkdtempSync(resolve(tmpdir(), "openiap-tool-download-")); + try { + const bin = resolve(fixture, "bin"); + mkdirSync(bin); + if (process.platform === "darwin") { + const sha256sum = resolve(bin, "sha256sum"); + writeFileSync(sha256sum, '#!/bin/sh\nexec shasum -a 256 "$@"\n'); + chmodSync(sha256sum, 0o755); + } + const curl = resolve(bin, "curl"); + writeFileSync( + curl, + `#!/usr/bin/env bash +printf '%s\\n' "$@" > "$TOOL_TEST_ARGUMENTS" +if [[ "$TOOL_TEST_DOWNLOAD_EXIT" != 0 ]]; then + exit "$TOOL_TEST_DOWNLOAD_EXIT" +fi +while [[ $# -gt 0 ]]; do + if [[ "$1" == --output ]]; then + printf 'corrupted executable' > "$2" + exit 0 + fi + shift +done +exit 2 +`, + ); + chmodSync(curl, 0o755); + for (const downloadExit of [22, 0]) { + const target = resolve(fixture, `codecov-${downloadExit}`); + const argumentsFile = resolve(fixture, "arguments"); + const result = spawnSync( + "bash", + [ + fileURLToPath(new URL("./install-security-tool.sh", import.meta.url)), + "codecov", + target, + ], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + TOOL_TEST_ARGUMENTS: argumentsFile, + TOOL_TEST_DOWNLOAD_EXIT: String(downloadExit), + }, + }, + ); + assert.equal(result.status, downloadExit || 1, result.stderr); + assert.equal(existsSync(target), false); + const args = readFileSync(argumentsFile, "utf8").trim().split("\n"); + for (const [option, value] of [ + ["--proto", "=https"], + ["--retry", "3"], + ["--connect-timeout", "15"], + ["--max-time", "300"], + ]) + assert.equal(args[args.indexOf(option) + 1], value); + assert.ok(args.includes("--tlsv1.2")); + assert.ok(args.includes("--retry-all-errors")); + assert.match( + args.at(-1), + /^https:\/\/github\.com\/codecov\/codecov-cli\/releases\/download\/v\d+\.\d+\.\d+\/codecovcli_linux$/u, + ); + if (downloadExit === 0) + assert.match( + result.stdout + result.stderr, + /checksum.*(match|FAILED)/iu, + ); + } + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); + test("workflow scan detects expressions in scalar and block run steps", () => { const workflow = `steps: - run: echo "${"${{"} inputs.scalar }}" @@ -181,6 +311,34 @@ steps: ); }); +test("local composite actions retain immutable dependencies and checkout guards", () => { + const filename = ".github/actions/coverage-report/action.yml"; + const action = `runs:\n using: composite\n steps:\n - uses: actions/upload-artifact@${"a".repeat(40)} # v7\n`; + assert.deepEqual(findWorkflowDependencyFindings(action, filename), []); + assert.match( + findWorkflowDependencyFindings( + action.replace("a".repeat(40), "v7"), + filename, + ).join("\n"), + /unpinned action/, + ); + assert.match( + findWorkflowDependencyFindings( + `permissions: write-all\n${action}`, + filename, + ).join("\n"), + /inherit permissions/, + ); + const checkout = action.replace( + "actions/upload-artifact", + "actions/checkout", + ); + assert.match( + findWorkflowDependencyFindings(checkout, filename).join("\n"), + /disable persisted credentials/, + ); +}); + test("workflow discovery covers both YAML extensions", (t) => { const scratch = mkdtempSync(resolve(tmpdir(), "openiap-workflows-")); writeFileSync(resolve(scratch, "first.yml"), "permissions: read-all\n"); @@ -291,18 +449,49 @@ test("bun audit parsing tolerates the CLI banner", () => { test("dependency audit fails closed on findings and malformed output", () => { const projects = [{ directory: ".", lockfile: "bun.lock" }]; + // The mock marks the real root exceptions used (keyed by advisory id, as + // in the Bun exception test), so only the unaccepted finding counts below. + // A retired root file means no exceptions, as in the audit itself. + const rootConfig = resolve(import.meta.dirname, "..", "osv-scanner.toml"); + const ignored = existsSync(rootConfig) + ? parseOsvIgnoredVulnerabilities(readFileSync(rootConfig, "utf8")) + : new Map(); + const findings = { + hono: [ + { + severity: "high", + title: "unsafe version", + url: "https://github.com/advisories/GHSA-test-test-test", + }, + ], + }; + for (const id of ignored.keys()) + findings[id] = [ + { + severity: "high", + title: "ignored issue", + url: `https://github.com/advisories/${id}`, + }, + ]; assert.throws( () => auditDependencies( - () => ({ - status: 1, - stdout: - '{"hono":[{"severity":"high","title":"unsafe version","url":"https://github.com/advisories/GHSA-test-test-test"}]}', - stderr: "", - }), + (command) => + command === "osv-scanner" + ? { + status: 0, + stdout: JSON.stringify({ results: [] }), + stderr: "", + } + : { + status: 1, + stdout: JSON.stringify(findings), + stderr: "", + }, projects, + new Date("2026-10-02T00:00:00Z"), ), - /1 dependency audit findings/u, + /(? @@ -359,6 +548,166 @@ reason = "Invalid date." ); }); +test("Bun lock exceptions require usage in either feed and expire on their date", () => { + const lockfile = "libraries/expo-iap/bun.lock"; + const projects = [{ directory: "libraries/expo-iap", lockfile }]; + // Ids and expiry come from the real exception file, so adding, retiring, + // or renewing one needs no edit here. Bun finding keys are the ids: the + // audit matches on the advisory URL, never the package name. + const exceptions = parseOsvIgnoredVulnerabilities( + readFileSync( + resolve(import.meta.dirname, "..", "libraries/expo-iap/osv-scanner.toml"), + "utf8", + ), + ); + assert.ok(exceptions.size > 0, "the fixture needs at least one exception"); + const forgeId = "GHSA-86w9-cpqp-85rv"; + const bunFinding = JSON.stringify( + Object.fromEntries( + [...exceptions.keys()].map((id) => [ + id, + [ + { + severity: "high", + title: "ignored issue", + url: `https://github.com/advisories/${id}`, + }, + ], + ]), + ), + ); + const osvFindings = [...exceptions.keys()].map((id) => + id === forgeId + ? { id: "CVE-2026-85393", aliases: [id], summary: "certificate issue" } + : { id }, + ); + const firstExpiry = [...exceptions.values()] + .map((entry) => Date.parse(`${entry.ignoreUntil}T00:00:00Z`)) + .reduce((a, b) => Math.min(a, b)); + const scanner = (bun, vulnerabilities) => (command, args) => { + if (command === "bun") + return { status: bun === "{}" ? 0 : 1, stdout: bun, stderr: "" }; + assert.equal(command, "osv-scanner"); + assert.ok(args.includes(`--lockfile=${lockfile}`)); + assert.ok(args.includes("--config=/dev/null")); + return { + status: vulnerabilities.length ? 1 : 0, + stdout: JSON.stringify({ + results: [{ packages: [{ vulnerabilities }] }], + }), + stderr: "", + }; + }; + const beforeExpiry = new Date("2026-10-02T00:00:00Z"); + assert.doesNotThrow(() => + auditDependencies(scanner("{}", osvFindings), projects, beforeExpiry), + ); + assert.doesNotThrow(() => + auditDependencies(scanner(bunFinding, []), projects, beforeExpiry), + ); + assert.throws( + () => auditDependencies(scanner("{}", []), projects, beforeExpiry), + /unused dependency exception/u, + ); + // ignoreUntil is the first expired day, so the boundary pins the earliest + // entry: at the latest one every entry is already expired. + assert.doesNotThrow(() => + auditDependencies( + scanner("{}", osvFindings), + projects, + new Date(firstExpiry - 1), + ), + ); + assert.throws( + () => + auditDependencies( + scanner("{}", osvFindings), + projects, + new Date(firstExpiry), + ), + /expired dependency exception/u, + ); + assert.throws( + () => + auditDependencies( + scanner("{}", [...osvFindings, { id: "GHSA-unaccepted" }]), + projects, + beforeExpiry, + ), + /GHSA-unaccepted/u, + ); +}); + +test("Bun OSV scans fail closed on incomplete or failed reports", () => { + const projects = [{ directory: ".", lockfile: "bun.lock" }]; + for (const [result, message] of [ + [ + { status: 0, stdout: '{"results":{}}' }, + /invalid OSV-Scanner result structure/u, + ], + [{ status: 1, stdout: '{"results":[]}' }, /exited 1 without findings/u], + [{ status: 2, stdout: '{"results":[]}' }, /OSV-Scanner exited 2/u], + [{ status: 0, stdout: "partial" }, /invalid OSV-Scanner JSON/u], + ]) { + assert.throws( + () => + auditDependencies( + (command) => + command === "bun" + ? { status: 0, stdout: "{}", stderr: "" } + : { stderr: "partial scan failure", ...result }, + projects, + ), + message, + ); + } +}); + +test("exception usage remains isolated to its owning lock", () => { + const projects = ["libraries/expo-iap", "libraries/expo-iap/example"].map( + (directory) => ({ directory, lockfile: `${directory}/bun.lock` }), + ); + const scanner = (emptyLock) => (command, args) => { + if (command === "bun") return { status: 0, stdout: "{}", stderr: "" }; + const lockfile = args + .find((arg) => arg.startsWith("--lockfile=")) + .slice("--lockfile=".length); + const directory = projects.find( + (project) => project.lockfile === lockfile, + ).directory; + const ignored = parseOsvIgnoredVulnerabilities( + readFileSync( + resolve(import.meta.dirname, "..", directory, "osv-scanner.toml"), + "utf8", + ), + ); + const vulnerabilities = + lockfile === emptyLock ? [] : [...ignored.keys()].map((id) => ({ id })); + return { + status: vulnerabilities.length ? 1 : 0, + stdout: JSON.stringify({ + results: [{ packages: [{ vulnerabilities }] }], + }), + stderr: "", + }; + }; + const now = new Date("2026-10-02T00:00:00Z"); + assert.doesNotThrow(() => auditDependencies(scanner(null), projects, now)); + for (const { lockfile } of projects) { + assert.throws( + () => auditDependencies(scanner(lockfile), projects, now), + (error) => { + assert.match(error.message, /unused dependency exception/u); + assert.match( + error.message, + new RegExp(lockfile.replaceAll(".", "\\."), "u"), + ); + return true; + }, + ); + } +}); + test("Yarn-only OSV exceptions cannot become stale or expired", () => { const yarnLock = "libraries/react-native-iap/yarn.lock"; // Ids and expiry both come from the real exception file, so adding or @@ -376,9 +725,11 @@ test("Yarn-only OSV exceptions cannot become stale or expired", () => { ), ); assert.ok(exceptions.size > 0, "the fixture needs at least one exception"); - const lastExpiry = [...exceptions.values()] - .map((entry) => Date.parse(`${entry.ignoreUntil}T00:00:00Z`)) - .reduce((a, b) => Math.max(a, b)); + const expiries = [...exceptions.values()].map((entry) => + Date.parse(`${entry.ignoreUntil}T00:00:00Z`), + ); + const firstExpiry = Math.min(...expiries); + const lastExpiry = Math.max(...expiries); const dayAfterLastExpiry = new Date(lastExpiry + 24 * 60 * 60 * 1000); const activeReport = JSON.stringify({ results: [ @@ -424,8 +775,13 @@ test("Yarn-only OSV exceptions cannot become stale or expired", () => { // OSV-Scanner stops honouring a window on the ignoreUntil date itself. This // audit once called that same day live, so a lapsed exception failed CI while // this stayed silent; the boundary is pinned rather than left to a comparison. + // It pins the earliest entry: at the latest one every entry is already + // expired, so that assertion cannot catch an off-by-one. + assert.doesNotThrow(() => + auditDependencies(scanner, [], new Date(firstExpiry - 1), [yarnLock]), + ); assert.throws( - () => auditDependencies(scanner, [], new Date(lastExpiry), [yarnLock]), + () => auditDependencies(scanner, [], new Date(firstExpiry), [yarnLock]), /expired dependency exception/u, ); const unaccepted = JSON.parse(activeReport); diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs index ddd03b907..e803742a8 100644 --- a/scripts/generate-sbom.mjs +++ b/scripts/generate-sbom.mjs @@ -62,7 +62,12 @@ export const PUBLISHED_METADATA_UNAVAILABLE_EXIT_CODE = 75; const ALL_OPENIAP_NATIVE_VARIANTS = { kind: "openiap-native", apple: true, - google: ["openiap-google", "openiap-google-horizon", "openiap-google-amazon"], + google: [ + "openiap-core", + "openiap-google", + "openiap-google-horizon", + "openiap-google-amazon", + ], }; const PODSPEC_DEPENDENCY_PATTERN = /^\s*s\.dependency\s+['"]([^'"]+)['"]/gmu; @@ -71,6 +76,17 @@ const GRADLE_COORDINATE_PATTERN = const GRADLE_PROJECT_PATTERN = /^\s*(?:implementation|api|runtimeOnly|compile)(?:\s+\(?\s*|\(\s*)project\(\s*['"]([^'"]+)['"]/gmu; +function storeResolverInventory(file, configuration, version, localProject) { + return { + file, + pattern: /^\s*(openIapAddStoreDependencies\([^\n]*\))\s*$/gmu, + expected: [], + resolverExpected: [ + `openIapAddStoreDependencies('${configuration}', ${version}, '${localProject}')`, + ], + }; +} + const LEGACY_SBOM_REPAIRS = new Map([ [ "google-3.3.0", @@ -302,12 +318,20 @@ const COMPONENTS = { "io.github.hyochan.openiap:openiap-google-horizon:${googleVersionString}", "io.github.hyochan.openiap:openiap-google:${googleVersionString}", ], + resolverExpected: [], }, { file: "libraries/expo-iap/android/build.gradle", pattern: GRADLE_PROJECT_PATTERN, expected: [":openiap-google"], + resolverExpected: [], }, + storeResolverInventory( + "libraries/expo-iap/android/build.gradle", + "api", + "googleVersionString", + ":openiap-google", + ), ], }, ], @@ -336,9 +360,13 @@ const COMPONENTS = { pattern: GRADLE_COORDINATE_PATTERN, expected: [ "androidx.annotation:annotation:${readRequiredAndroidGradleProperty(projectDir, 'openIapAndroidAnnotationVersion')}", + "org.jetbrains.kotlinx:kotlinx-coroutines-android:${readRequiredAndroidGradleProperty(projectDir, 'openIapKotlinxCoroutinesVersion')}", "io.github.hyochan.openiap:openiap-google-amazon:${openiapGoogleVersion}", "io.github.hyochan.openiap:openiap-google-horizon:${openiapGoogleVersion}", "io.github.hyochan.openiap:openiap-google:${openiapGoogleVersion}", + ], + resolverExpected: [ + "androidx.annotation:annotation:${readRequiredAndroidGradleProperty(projectDir, 'openIapAndroidAnnotationVersion')}", "org.jetbrains.kotlinx:kotlinx-coroutines-android:${readRequiredAndroidGradleProperty(projectDir, 'openIapKotlinxCoroutinesVersion')}", ], }, @@ -346,7 +374,14 @@ const COMPONENTS = { file: "libraries/flutter_inapp_purchase/android/build.gradle", pattern: GRADLE_PROJECT_PATTERN, expected: [":openiap"], + resolverExpected: [], }, + storeResolverInventory( + "libraries/flutter_inapp_purchase/android/build.gradle", + "implementation", + "openiapGoogleVersion", + ":openiap", + ), ], dependencies: [ { @@ -429,7 +464,7 @@ const COMPONENTS = { }, }, }, - { kind: "openiap-native", apple: true, google: [] }, + { kind: "openiap-native", apple: true, google: ["openiap-google"] }, { kind: "embedded-binary", file: "libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime", @@ -526,7 +561,7 @@ const COMPONENTS = { { kind: "openiap-native", apple: true, - google: ["openiap-google"], + google: ["openiap-core", "openiap-google"], }, ], }, @@ -591,9 +626,14 @@ const COMPONENTS = { expected: [ "com.facebook.react:react-native:+", "com.google.android.gms:play-services-base:$playServicesBaseVersion", + "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion", "io.github.hyochan.openiap:openiap-google-amazon:${googleVersionString}", "io.github.hyochan.openiap:openiap-google-horizon:${googleVersionString}", "io.github.hyochan.openiap:openiap-google:${googleVersionString}", + ], + resolverExpected: [ + "com.facebook.react:react-native:+", + "com.google.android.gms:play-services-base:$playServicesBaseVersion", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion", ], }, @@ -601,7 +641,14 @@ const COMPONENTS = { file: "libraries/react-native-iap/android/build.gradle", pattern: GRADLE_PROJECT_PATTERN, expected: [":openiap", ":react-native-nitro-modules"], + resolverExpected: [":react-native-nitro-modules"], }, + storeResolverInventory( + "libraries/react-native-iap/android/build.gradle", + "implementation", + "googleVersionString", + ":openiap", + ), ], dependencies: [ { diff --git a/scripts/generate-sbom.test.mjs b/scripts/generate-sbom.test.mjs index 25f58a05f..16283c36e 100644 --- a/scripts/generate-sbom.test.mjs +++ b/scripts/generate-sbom.test.mjs @@ -1487,7 +1487,7 @@ test("published metadata matches the consumer-visible artifacts", async () => { const kmp = await generateSbom("kmp", { root: repoRoot, runGit: stubGit }); const kmpNames = kmp.document.components.map((entry) => entry.name); - assert.equal(kmp.directCount, 8); + assert.equal(kmp.directCount, 9); assert.ok(kmpNames.includes("openiap")); assert.ok(kmpNames.includes("io.github.hyochan.openiap:openiap-google")); assert.ok( @@ -1501,7 +1501,12 @@ test("published metadata matches the consumer-visible artifacts", async () => { assert.ok(!kmpNames.some((name) => name.endsWith("-jvm"))); const maui = await generateSbom("maui", { root: repoRoot, runGit: stubGit }); - assert.equal(maui.directCount, 24); + assert.equal(maui.directCount, 25); + assert.ok( + maui.document.components.some( + (entry) => entry.name === "io.github.hyochan.openiap:openiap-core", + ), + ); assert.ok(maui.document.components.some((entry) => entry.name === "openiap")); assert.ok( maui.document.components.some( @@ -1679,6 +1684,7 @@ test("framework SBOMs include every shipped native runtime contract", async () = const openIapNativeNames = [ "openiap", + "io.github.hyochan.openiap:openiap-core", "io.github.hyochan.openiap:openiap-google", "io.github.hyochan.openiap:openiap-google-amazon", "io.github.hyochan.openiap:openiap-google-horizon", @@ -2975,9 +2981,13 @@ test("the godot SBOM matches the dependency contract the addon ships", async () ); const remote = gdap.match(/^remote=\[(.*)\]$/m); assert.ok(remote, "GodotIap.gdap declares no remote dependency list"); - const declared = [...remote[1].matchAll(/"([^"]+)"/g)] - .map((match) => match[1]) - .sort(); + const nativeVersions = JSON.parse( + readFileSync(resolve(repoRoot, "openiap-versions.json"), "utf8"), + ); + const declared = [ + ...[...remote[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]), + `io.github.hyochan.openiap:openiap-core:${nativeVersions.google}`, + ].sort(); // withLicenses is off, so this resolves from committed manifests only. const { document } = await generateSbom("godot", { root: repoRoot }); @@ -3224,3 +3234,98 @@ test("pub version ranges lose YAML quoting without losing constraint bounds", () rmSync(root, { recursive: true, force: true }); } }); + +test("SDK resolver inventories retain legacy declarations and reject missing or extra dependencies", async (t) => { + const scratch = mkdtempSync(resolve(tmpdir(), "openiap-resolver-sbom-")); + t.after(() => rmSync(scratch, { recursive: true, force: true })); + const manifest = "libraries/expo-iap/android/build.gradle"; + mkdirSync(dirname(resolve(scratch, manifest)), { recursive: true }); + const source = COMPONENTS.expo.source.sources.find( + (entry) => entry.kind === "declared" && entry.dependencies.length === 0, + ); + const call = + "openIapAddStoreDependencies('api', googleVersionString, ':openiap-google')"; + const write = (body) => + writeFileSync(resolve(scratch, manifest), `dependencies {\n${body}\n}\n`); + write(call); + assert.deepEqual(await extractDirectDependencies(scratch, source), []); + for (const body of [ + "", + call.replace("'api'", "'compileOnly'"), + `${call}\napi 'example:extra:1.0.0'`, + ]) { + write(body); + await assert.rejects( + () => extractDirectDependencies(scratch, source), + /Unmodelled dependency declaration/u, + ); + } + write( + [ + ...[ + "openiap-google-amazon", + "openiap-google-horizon", + "openiap-google", + ].map( + (artifact) => + `api "io.github.hyochan.openiap:${artifact}:\${googleVersionString}"`, + ), + "api project(':openiap-google')", + ].join("\n"), + ); + assert.deepEqual(await extractDirectDependencies(scratch, source), []); +}); + +test("a local core dependency requires its published neutral fallback", (t) => { + const scratch = mkdtempSync(resolve(tmpdir(), "openiap-core-sbom-")); + t.after(() => rmSync(scratch, { recursive: true, force: true })); + const manifest = "build.gradle.kts"; + writeFileSync( + resolve(scratch, manifest), + 'dependencies { implementation(project(":openiap-core")) }', + ); + assert.throws( + () => extractGradle(scratch, { manifest }), + /lacks its published fallback/u, + ); + writeFileSync( + resolve(scratch, manifest), + 'dependencies { implementation(project(":openiap-core")); implementation("io.github.hyochan.openiap:openiap-core:4.0.0") }', + ); + assert.equal( + extractGradle(scratch, { manifest })[0].name, + "io.github.hyochan.openiap:openiap-core", + ); +}); + +test("legacy native SBOMs exclude core while provider releases require its manifest", async (t) => { + const scratch = mkdtempSync(resolve(tmpdir(), "openiap-legacy-core-sbom-")); + t.after(() => rmSync(scratch, { recursive: true, force: true })); + const source = { + kind: "openiap-native", + google: ["openiap-core", "openiap-google"], + }; + writeFileSync( + resolve(scratch, "openiap-versions.json"), + JSON.stringify({ google: "3.6.1" }), + ); + assert.deepEqual( + (await extractDirectDependencies(scratch, source)).map( + (entry) => entry.name, + ), + ["io.github.hyochan.openiap:openiap-google"], + ); + writeFileSync( + resolve(scratch, "openiap-versions.json"), + JSON.stringify({ google: "4.0.0" }), + ); + await assert.rejects( + () => extractDirectDependencies(scratch, source), + /Missing openiap-core manifest/u, + ); + mkdirSync(resolve(scratch, "packages/google/core"), { + recursive: true, + }); + writeFileSync(resolve(scratch, "packages/google/core/build.gradle.kts"), ""); + assert.equal((await extractDirectDependencies(scratch, source)).length, 2); +}); diff --git a/scripts/generate-store-registry.mjs b/scripts/generate-store-registry.mjs new file mode 100644 index 000000000..455ad9acd --- /dev/null +++ b/scripts/generate-store-registry.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { format, resolveConfig } from "prettier"; +import { + SUITE_VERSION, + SUITE_MAJOR_RELEASE_DATE, +} from "../packages/conformance/src/spec/suite-version.mjs"; +import { + validateStoreRegistry, + maintenanceStatus, + storeBindings, +} from "../specs/client/store-registry.mjs"; + +const root = new URL("../", import.meta.url); +const registry = validateStoreRegistry( + JSON.parse( + readFileSync(new URL("specs/client/src/store-registry.json", root), "utf8"), + ), +); +const android = registry.stores.filter((store) => + storeBindings(store).some((binding) => binding.platform === "android"), +); +const aliases = android.flatMap((store) => + [store.id, ...store.aliases].map((alias) => [alias, store.id]), +); +const controls = Object.entries(registry.selectionAliases); +const check = process.argv.includes("--check"); +const drift = []; +function update(path, transform) { + const file = new URL(path, root); + const previous = readFileSync(file, "utf8"); + const next = transform(previous); + if (previous === next) return; + if (check) drift.push(path); + else { + writeFileSync(file, next); + console.log(`generated ${path}`); + } +} +function replaceBlock(source, pattern, replacement, label) { + if (!pattern.test(source)) + throw new Error(`Cannot find ${label} generation block`); + return source.replace(pattern, replacement); +} + +update("packages/google/gradle/openiap-store.gradle", (source) => + replaceBlock( + source, + /ext\.openIapStoreAliases = \[[\s\S]*?\n\]/, + `ext.openIapStoreAliases = [\n${[...aliases, ...controls].map(([alias, id]) => ` '${alias}': '${id}',`).join("\n")}\n]`, + "Gradle aliases", + ), +); +update("packages/cli/src/checks.mjs", (source) => + replaceBlock( + source, + /export const STORE_ALIASES = \{[\s\S]*?\n\};/, + `export const STORE_ALIASES = {\n${[...aliases, ...controls].map(([alias, id]) => ` "${alias}": "${id}",`).join("\n")}\n};`, + "doctor aliases", + ), +); +update("libraries/expo-iap/plugin/src/withIAP.ts", (source) => + replaceBlock( + source, + /const ANDROID_STORE_ALIASES: Record = \{[\s\S]*?\n\};/, + `const ANDROID_STORE_ALIASES: Record = {\n${[...aliases, ...controls].map(([alias, id]) => ` "${alias}": "${id}",`).join("\n")}\n};`, + "Expo plugin aliases", + ), +); +update( + "packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt", + (source) => + replaceBlock( + source, + /private val storeAliases = mapOf\([\s\S]*?\n\)/, + `private val storeAliases = mapOf(\n${aliases + .filter(([alias, id]) => alias !== id) + .map(([alias, id]) => ` "${alias}" to "${id}",`) + .join("\n")}\n)`, + "native aliases", + ), +); +update("libraries/godot-iap/addons/godot-iap/android_store.gd", (source) => { + const stores = ["auto", ...android.map((store) => store.id)]; + source = replaceBlock( + source, + /const STORES: PackedStringArray = \[[^\n]+\]/, + `const STORES: PackedStringArray = [${stores.map(JSON.stringify).join(", ")}]`, + "Godot suggestions", + ); + return replaceBlock( + source, + /const ALIASES := \{[\s\S]*?\n\}/, + `const ALIASES := {\n${[...aliases, ...controls.filter(([, id]) => id !== "none")].map(([alias, id]) => `\t"${alias}": "${id}",`).join("\n")}\n}`, + "Godot aliases", + ); +}); +update( + "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.targets", + (source) => + replaceBlock( + source, + / <_OpenIapStoreName Include="[^\n]+[\s\S]*?(?=\n <\/ItemGroup>)/, + ` <_OpenIapStoreName Include="auto" Store="auto" />\n${android.map((store) => ` <_OpenIapStoreName Include="${[store.id, ...store.aliases].join(";")}" Store="${store.id}" />`).join("\n")}`, + "MAUI aliases", + ), +); + +const versions = JSON.parse( + readFileSync(new URL("openiap-versions.json", root), "utf8"), +); +const formatCoordinates = (store, binding) => + typeof binding.coordinates === "string" + ? binding.coordinates + : binding.platform === "android" + ? `${binding.coordinates.module}:${versions[binding.coordinates.versionKey]}` + : store.tier === "official" + ? `${binding.coordinates.repository}@${versions[binding.coordinates.versionKey]}` + : `${binding.coordinates.package}@${binding.coordinates.version} (${binding.coordinates.product})`; +const data = registry.stores.map((store) => { + const bindings = storeBindings(store); + return { + ...store, + platform: bindings.map((binding) => binding.platform).join(", "), + capabilities: [ + ...new Set(bindings.flatMap((binding) => binding.capabilities)), + ], + status: maintenanceStatus(store, registry.maintenanceWindowDays), + coordinates: bindings + .map( + (binding) => + `${bindings.length > 1 ? `${binding.platform}: ` : ""}${formatCoordinates(store, binding)}`, + ) + .join("; "), + latestReport: + bindings.find((binding) => binding.latestReport)?.latestReport.url ?? + null, + reports: bindings + .filter((binding) => binding.latestReport) + .map((binding) => ({ + platform: binding.platform, + url: binding.latestReport.url, + })), + }; +}); +const docsPath = "packages/docs/src/generated/store-registry.json"; +const docsFile = new URL(docsPath, root); +const docs = await format( + JSON.stringify({ + suiteVersion: SUITE_VERSION, + maintenanceWindowDays: registry.maintenanceWindowDays, + suiteMajorAdoptionDate: SUITE_MAJOR_RELEASE_DATE, + stores: data, + }), + { + ...(await resolveConfig(docsFile.pathname)), + parser: "json", + }, +); +let previous = ""; +try { + previous = readFileSync(docsFile, "utf8"); +} catch {} +if (previous !== docs) { + if (check) drift.push(docsPath); + else { + writeFileSync(docsFile, docs); + console.log(`generated ${docsPath}`); + } +} +for (const store of data.filter((store) => + ["outdated", "unmaintained"].includes(store.status), +)) { + console.warn( + `::warning title=OpenIAP provider ${store.status}::${store.id}: conformance report is behind the current suite major`, + ); +} +if (drift.length) { + console.error( + `Store registry generation drift:\n${drift.map((file) => `- ${file}`).join("\n")}\nRun bun run stores:generate`, + ); + process.exitCode = 1; +} else if (check) + console.log("Store registry and generated surfaces are in sync."); diff --git a/scripts/install-security-tool.sh b/scripts/install-security-tool.sh index b0cea0a04..f11e48593 100755 --- a/scripts/install-security-tool.sh +++ b/scripts/install-security-tool.sh @@ -4,7 +4,7 @@ set -euo pipefail TOOL=${1:-} TARGET=${2:-} if [[ -z "$TOOL" || -z "$TARGET" ]]; then - echo "Usage: install-security-tool.sh TARGET" >&2 + echo "Usage: install-security-tool.sh TARGET" >&2 exit 1 fi @@ -12,6 +12,11 @@ SECURITY_TOOL_TMP=$(mktemp -d) trap 'rm -rf "$SECURITY_TOOL_TMP"' EXIT case "$TOOL" in + codecov) + ARCHIVE="$SECURITY_TOOL_TMP/codecov" + URL=https://github.com/codecov/codecov-cli/releases/download/v11.3.1/codecovcli_linux + SHA256=ca1d64196d2d34771084afe76ea657d581bf628e31d993ff8e52ea09cc88a56d + ;; cyclonedx) ARCHIVE="$SECURITY_TOOL_TMP/cyclonedx" URL=https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.33.1/cyclonedx-linux-x64 diff --git a/scripts/provider-release-preflight.test.mjs b/scripts/provider-release-preflight.test.mjs new file mode 100644 index 000000000..d01491d0c --- /dev/null +++ b/scripts/provider-release-preflight.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const workflow = readFileSync( + new URL("../.github/workflows/release-google.yml", import.meta.url), + "utf8", +); +const preflight = workflow + .split(" - name: Preflight Maven Central publication\n")[1] + .split(" run: |\n")[1] + .split("\n - name:")[0] + .replace(/^ /gm, ""); +const base = { + CORE_EXISTS: "false", + CONFORMANCE_EXISTS: "false", + HORIZON_EXISTS: "false", + AMAZON_EXISTS: "false", + PLAY_EXISTS: "false", + PLUGIN_EXISTS: "false", + RELEASE_TAG_EXISTS: "false", +}; +const credentials = Object.fromEntries( + [ + "MAVEN_CENTRAL_USERNAME", + "MAVEN_CENTRAL_PASSWORD", + "GPG_KEY_CONTENTS", + "SIGNING_KEY_ID", + "SIGNING_PASSWORD", + ].map((key) => [key, "fixture"]), +); +function check(values, legacy = false) { + const root = mkdtempSync(join(tmpdir(), "provider-preflight-")); + if (!legacy) { + mkdirSync(join(root, "packages/google/core"), { recursive: true }); + writeFileSync(join(root, "packages/google/core/build.gradle.kts"), ""); + } + try { + return spawnSync("bash", ["-e", "-c", preflight], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + ...Object.fromEntries(Object.keys(credentials).map((key) => [key, ""])), + ...base, + ...values, + }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} +test("new provider artifacts require credentials before a release tag exists", () => { + assert.notEqual(check({}).status, 0); + assert.equal(check(credentials).status, 0); +}); +test("an orphan core publication cannot be attached to a fresh Google release", () => { + assert.notEqual(check({ ...credentials, CORE_EXISTS: "true" }).status, 0); +}); +test("the independent conformance version may already exist on a new Google release", () => { + assert.equal(check({ ...credentials, CONFORMANCE_EXISTS: "true" }).status, 0); +}); +test("a complete existing release needs no publishing credentials", () => { + assert.equal( + check(Object.fromEntries(Object.keys(base).map((key) => [key, "true"]))) + .status, + 0, + ); +}); +test("legacy release tags do not require an artifact that did not exist at that tag", () => { + assert.equal( + check( + Object.fromEntries(Object.keys(base).map((key) => [key, "true"])), + true, + ).status, + 0, + ); +}); +test("provider prerequisites publish before official stores and stable conformance skips prereleases", () => { + assert.ok( + workflow.indexOf("name: Publish core to Maven Central") < + workflow.indexOf("name: Publish Android conformance to Maven Central"), + ); + assert.ok( + workflow.indexOf("name: Publish Android conformance to Maven Central") < + workflow.indexOf("name: Publish Horizon flavor to Maven Central"), + ); + assert.match( + workflow, + /if \[ "\$IS_PRERELEASE" != "true" \]; then\n\s+CONFORMANCE_EXISTS=/, + ); +}); + +function artifactStep(name) { + return workflow + .split(` - name: ${name}\n`)[1] + .split(" run: |\n")[1] + .split("\n - name:")[0] + .replace(/^ /gm, ""); +} +function collectArtifacts({ legacy = false, missingCore = false } = {}) { + const root = mkdtempSync(join(tmpdir(), "provider-artifacts-")); + try { + if (!legacy) { + mkdirSync(join(root, "core"), { recursive: true }); + writeFileSync(join(root, "core/build.gradle.kts"), ""); + } + writeFileSync( + join(root, "gradlew"), + `#!/bin/bash\nfor task in "$@"; do\ncase "$task" in\n:openiap:assembleRelease) mkdir -p openiap/build/outputs/aar; touch openiap/build/outputs/aar/openiap-play-release.aar ;;\n:openiap-core:assembleRelease) ${missingCore ? ":" : "mkdir -p core/build/outputs/aar; touch core/build/outputs/aar/openiap-core-release.aar"} ;;\nesac\ndone\n`, + { mode: 0o755 }, + ); + const result = spawnSync( + "bash", + [ + "-e", + "-c", + artifactStep("Build release artifacts") + + "\n" + + artifactStep("Create release artifacts"), + ], + { cwd: root, encoding: "utf8" }, + ); + return { + status: result.status, + output: result.stdout + result.stderr, + listing: + result.status === 0 + ? spawnSync("unzip", ["-l", "release-artifacts.zip"], { + cwd: root, + encoding: "utf8", + }).stdout + : "", + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} +test("release ZIP contains the explicitly assembled provider core", () => { + const result = collectArtifacts(); + assert.equal(result.status, 0, result.output); + assert.match(result.listing, /openiap-core-release\.aar/); +}); +test("a missing provider core stops artifact collection", () => { + const result = collectArtifacts({ missingCore: true }); + assert.notEqual(result.status, 0); + assert.match(result.output, /provider core AAR is missing/); +}); +test("legacy release tags collect artifacts without a core module", () => { + const result = collectArtifacts({ legacy: true }); + assert.equal(result.status, 0, result.output); + assert.doesNotMatch(result.listing, /openiap-core/); +}); diff --git a/scripts/release-branch-policy.mjs b/scripts/release-branch-policy.mjs index 5d319a7c5..89558bfd4 100644 --- a/scripts/release-branch-policy.mjs +++ b/scripts/release-branch-policy.mjs @@ -354,12 +354,21 @@ export const publishedSources = { google: [ /^packages\/google\/openiap\/src\/(main|play|horizon|amazon)\//, /^packages\/google\/openiap\/(build\.gradle\.kts|consumer-rules[\w-]*\.pro)$/, + // openiap-core ships its own manifest, keep rules, and build script; its + // classes come from openiap/src/main, already listed above. + /^packages\/google\/core\/src\/main\//, + /^packages\/google\/core\/(build\.gradle\.kts|consumer-rules[\w-]*\.pro)$/, + // The google release also publishes the openiap-conformance artifact. + /^packages\/conformance\/android\/src\/main\//, + /^packages\/conformance\/android\/build\.gradle\.kts$/, /^packages\/google\/gradle\/[^/]+\.gradle$/, /^packages\/google\/gradle-plugin\/(src\/main\/|build\.gradle\.kts$)/, ], apple: [ /^packages\/apple\/Sources\//, /^packages\/apple\/(Package\.swift|openiap\.podspec)$/, + // Every Apple tag ships the OpenIapConformance product too. + /^packages\/conformance\/apple\/Sources\//, // SwiftPM and CocoaPods git sources resolve openiap-apple from the root. /^(Package\.swift|openiap\.podspec)$/, ], @@ -414,11 +423,16 @@ export function shipsInAnyPackage(file) { export const nativeReleaseGates = { google: { tag: (version) => `google-${version}`, - roots: ["packages/google"], + roots: ["packages/google", "packages/conformance/android"], }, apple: { tag: (version) => version, - roots: ["packages/apple", "Package.swift", "openiap.podspec"], + roots: [ + "packages/apple", + "packages/conformance/apple", + "Package.swift", + "openiap.podspec", + ], }, }; diff --git a/scripts/release-branch-policy.test.mjs b/scripts/release-branch-policy.test.mjs index 6e1a9df82..69fbdb1ae 100644 --- a/scripts/release-branch-policy.test.mjs +++ b/scripts/release-branch-policy.test.mjs @@ -21,11 +21,14 @@ import { findUnreleasedNativeChanges, libraryReleaseTags, nativeGateSkipReason, + nativeReleaseGates, assertReleaseBranch, compareSemVer, findPrereleaseVersions, isPrereleaseVersion, clientProtocolVersion, + shipsIn, + shipsInAnyPackage, versionSources, normalizeBranch, resolveReleaseChannel, @@ -34,6 +37,7 @@ import { withUpdatedNativeVersion, } from "./release-branch-policy.mjs"; import { assertReleaseTag } from "./assert-release-tag.mjs"; +import { findUnnotedSourceChanges } from "./audit-release-notes.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -2097,6 +2101,94 @@ test("the native gate counts only what the native packages ship", () => { ]); }); +test("the release gates know every native publication input", () => { + const published = [ + "packages/google/core/build.gradle.kts", + "packages/google/core/consumer-rules.pro", + "packages/google/core/src/main/AndroidManifest.xml", + "packages/conformance/android/build.gradle.kts", + "packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceSuite.kt", + "packages/conformance/apple/Sources/ProviderConformance.swift", + ]; + for (const file of published) { + assert.equal(shipsInAnyPackage(file), true, file); + } + assert.equal( + shipsIn("google", "packages/google/core/consumer-rules.pro"), + true, + ); + assert.equal( + shipsIn("google", "packages/conformance/android/build.gradle.kts"), + true, + ); + assert.equal( + shipsIn( + "apple", + "packages/conformance/apple/Sources/ProviderConformance.swift", + ), + true, + ); + assert.deepEqual(findUnnotedSourceChanges(published), published); + for (const file of [ + "packages/google/core/src/test/java/dev/hyo/openiap/OpenIapProviderTest.kt", + "packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt", + "packages/conformance/test/runner.test.mjs", + ]) { + assert.equal(shipsInAnyPackage(file), false, file); + } + assert.deepEqual( + findUnnotedSourceChanges([ + "packages/google/core/src/test/java/dev/hyo/openiap/OpenIapProviderTest.kt", + ]), + [], + ); +}); + +test("the native gate flags core and conformance-only changes", () => { + const seenRoots = {}; + const git = (args) => { + seenRoots[rangeOf(args)] = args.slice(args.indexOf("--") + 1); + return rangeOf(args) === "google-3.6.1..HEAD" + ? logEntry( + "aaaaaaa fix(core): keep provider factories", + "packages/google/core/consumer-rules.pro", + ) + + logEntry( + "bbbbbbb feat(conformance): extend the android suite", + "packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/StoreConformanceSuite.kt", + ) + : logEntry( + "ccccccc feat(conformance): extend the swift suite", + "packages/conformance/apple/Sources/ProviderConformance.swift", + ); + }; + assert.deepEqual(findUnreleasedNativeChanges(nativeVersions, { git }), [ + { + label: "openiap-google", + tag: "google-3.6.1", + commits: [ + "aaaaaaa fix(core): keep provider factories", + "bbbbbbb feat(conformance): extend the android suite", + ], + }, + { + label: "openiap-apple", + tag: "3.6.0", + commits: ["ccccccc feat(conformance): extend the swift suite"], + }, + ]); + assert.deepEqual(seenRoots["google-3.6.1..HEAD"], [ + "packages/google", + "packages/conformance/android", + ]); + assert.deepEqual(seenRoots["3.6.0..HEAD"], [ + "packages/apple", + "packages/conformance/apple", + "Package.swift", + "openiap.podspec", + ]); +}); + test("the native gate refuses a library release while a native change is unreleased", () => { const git = (args) => rangeOf(args) === "3.6.0..HEAD" diff --git a/scripts/release-openiap.test.mjs b/scripts/release-openiap.test.mjs index 8f73a558f..26920dc21 100644 --- a/scripts/release-openiap.test.mjs +++ b/scripts/release-openiap.test.mjs @@ -306,13 +306,15 @@ test("the release commit stages the mirror the bump moves", () => { "openiap-versions.json", "packages/docs/openiap-versions.json", "packages/apple/Sources/OpenIapGeneratedVersion.swift", + "libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props", "packages/docs/src/generated/version-metadata.json", "packages/conformance/src/spec/generated-spec.mjs", ]) { assert.ok( - new RegExp(`^\\s+${generated.replaceAll("/", "\\/").replaceAll(".", "\\.")} \\\\$`, "m").test( - script, - ), + new RegExp( + `^\\s+${generated.replaceAll("/", "\\/").replaceAll(".", "\\.")} \\\\$`, + "m", + ).test(script), `sync-release-generated.sh must stage ${generated}`, ); } @@ -340,7 +342,26 @@ test("Commerce release commits have browser smoke prerequisites", () => { ); const commit = steps.findIndex((step) => step.run?.includes("git commit -m")); assert.ok(dependencies >= 0 && dependencies < browser && browser < commit); - assert.equal(steps[browser].if, "${{ env.PACKAGE_ID == 'commerce-protocol' }}"); + assert.equal( + steps[browser].if, + "${{ env.PACKAGE_ID == 'commerce-protocol' }}", + ); +}); + +test("every MAUI native validation and publishing job assembles the core AAR", () => { + const workflow = parse( + readFileSync(join(root, ".github/workflows/release-maui.yml"), "utf8"), + ); + const builds = Object.values(workflow.jobs).flatMap((job) => + (job.steps ?? []).filter( + (step) => + step["working-directory"] === "packages/google" && + step.run?.includes(":openiap:assemblePlayRelease"), + ), + ); + assert.equal(builds.length, 2); + for (const step of builds) + assert.match(step.run, /:openiap-core:assembleRelease/); }); test("workflow exact bumps write alpha, beta, and stable versions to the selected manifest", (t) => { diff --git a/scripts/sbom-dependencies.mjs b/scripts/sbom-dependencies.mjs index 3bbf21674..0337fb974 100644 --- a/scripts/sbom-dependencies.mjs +++ b/scripts/sbom-dependencies.mjs @@ -9,7 +9,7 @@ */ import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { compareSemVer } from "./release-branch-policy.mjs"; @@ -533,7 +533,7 @@ function extractGradle(root, { manifest, externalLocals }) { const matchedDeclarations = new Set(); const topLevelCalls = topLevelGradleCalls(dependencies); const topLevelCallIndices = new Set(topLevelCalls.map((call) => call.index)); - let usesLocalOpeniapProject = false; + const localOpeniapProjects = new Set(); const record = (configuration, rawCoordinate) => { if (!isRuntimeGradleConfiguration(configuration)) return; const parsed = parseMavenCoordinate( @@ -568,22 +568,28 @@ function extractGradle(root, { manifest, externalLocals }) { } if (!isRuntimeGradleConfiguration(call.name)) continue; const argument = dependencies.slice(call.index + call.text.length); - if (/^\s*project\s*\(\s*":openiap"\s*\)/u.test(argument)) { - usesLocalOpeniapProject = true; + const localProject = argument.match( + /^\s*\(?\s*project\s*\(\s*"(:openiap(?:-core)?)"\s*\)/u, + )?.[1]; + if (localProject) { + localOpeniapProjects.add(localProject); continue; } throw new Error(`Unsupported Gradle dependency declaration in ${manifest}`); } - if ( - usesLocalOpeniapProject && - ![...found.values()].some( - (entry) => entry.name === "io.github.hyochan.openiap:openiap-google", - ) - ) { - throw new Error( - `Local :openiap dependency in ${manifest} lacks its published fallback`, - ); + for (const project of localOpeniapProjects) { + const artifact = + project === ":openiap-core" ? "openiap-core" : "openiap-google"; + if ( + ![...found.values()].some( + (entry) => entry.name === `io.github.hyochan.openiap:${artifact}`, + ) + ) { + throw new Error( + `Local ${project} dependency in ${manifest} lacks its published fallback`, + ); + } } return [...found.values()].sort((left, right) => @@ -1016,6 +1022,15 @@ function extractOpenIapNative(root, { apple = false, google = [] }) { ); } for (const artifact of google) { + if ( + artifact === "openiap-core" && + !existsSync(resolve(root, "packages/google/core/build.gradle.kts")) + ) { + if (compareSemVer(versions.google, "4.0.0") < 0) continue; + throw new Error( + "Missing openiap-core manifest for a provider-capable release", + ); + } entries.push( dependencyEntry({ name: `io.github.hyochan.openiap:${artifact}`, @@ -1049,7 +1064,14 @@ function verifyDeclaredInventory(root, inventories = []) { const actual = [...source.matchAll(inventory.pattern)] .map((match) => match.slice(1).find((value) => value !== undefined)) .sort(); - const expected = [...inventory.expected].sort(); + const usesStoreResolver = /^\s*openIapAddStoreDependencies\s*\(/mu.test( + source, + ); + const expected = [ + ...(usesStoreResolver && inventory.resolverExpected + ? inventory.resolverExpected + : inventory.expected), + ].sort(); if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error( `Unmodelled dependency declaration in ${inventory.file}: expected ${expected.join(", ")}; found ${actual.join(", ")}`, diff --git a/scripts/scheduled-removals.mjs b/scripts/scheduled-removals.mjs index 800f75274..558dbd470 100644 --- a/scripts/scheduled-removals.mjs +++ b/scripts/scheduled-removals.mjs @@ -31,25 +31,25 @@ export const scheduledRemovalRules = [ packages: [ { name: "openiap-google", - major: 3, + major: 4, file: "openiap-versions.json", pattern: /"google":\s*"([^"]+)"/, }, { name: "react-native-iap", - major: 16, + major: 17, file: "libraries/react-native-iap/package.json", pattern: PACKAGE_JSON_VERSION, }, { name: "expo-iap", - major: 5, + major: 6, file: "libraries/expo-iap/package.json", pattern: PACKAGE_JSON_VERSION, }, { name: "flutter_inapp_purchase", - major: 10, + major: 11, file: "libraries/flutter_inapp_purchase/pubspec.yaml", pattern: /^version:\s*(\S+)/m, }, @@ -67,7 +67,7 @@ export const scheduledRemovalRules = [ packages: [ { name: "expo-iap", - major: 5, + major: 6, file: "libraries/expo-iap/package.json", pattern: PACKAGE_JSON_VERSION, }, @@ -89,7 +89,7 @@ export const scheduledRemovalRules = [ packages: [ { name: "expo-iap", - major: 5, + major: 6, file: "libraries/expo-iap/package.json", pattern: PACKAGE_JSON_VERSION, }, @@ -107,7 +107,7 @@ export const scheduledRemovalRules = [ packages: [ { name: "OpenIap.Maui", - major: 2, + major: 3, file: "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", pattern: /([^<]+)<\/PackageVersion>/, }, @@ -125,7 +125,7 @@ export const scheduledRemovalRules = [ packages: [ { name: "react-native-iap", - major: 16, + major: 17, file: "libraries/react-native-iap/package.json", pattern: PACKAGE_JSON_VERSION, }, diff --git a/scripts/store-registry.test.mjs b/scripts/store-registry.test.mjs new file mode 100644 index 000000000..d935590f0 --- /dev/null +++ b/scripts/store-registry.test.mjs @@ -0,0 +1,383 @@ +import assert from "node:assert/strict"; +import { DART_KEYWORDS } from "../specs/client/codegen/core/dart-keywords.mjs"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + maintenanceStatus, + validateProviderReport, + validateStoreRegistry, +} from "../specs/client/store-registry.mjs"; +import { requiredProviderBehaviors } from "../packages/conformance/src/spec/android-provider-profile.mjs"; +import { SUITE_VERSION } from "../packages/conformance/src/spec/suite-version.mjs"; + +const registry = () => + JSON.parse( + readFileSync( + new URL("../specs/client/src/store-registry.json", import.meta.url), + "utf8", + ), + ); +const report = (capabilities = ["pendingPurchases"]) => { + const required = requiredProviderBehaviors(capabilities); + return { + suiteVersion: SUITE_VERSION, + clientProtocolVersion: "0.1.1", + store: "unknown", + storeId: "community-fixture", + capabilities, + conformant: true, + scope: { + kind: "android-provider", + complete: true, + requiredBehaviors: required, + }, + results: required.map((id) => ({ id, outcome: "pass" })), + }; +}; +const community = () => ({ + id: "community-fixture", + platform: "android", + displayName: "Fixture", + tier: "community", + aliases: ["fixture"], + maintainers: ["example"], + repo: "https://github.com/example/provider", + coordinates: "dev.example:provider:1.0.0", + capabilities: ["pendingPurchases"], + latestReport: { + url: "https://github.com/example/provider/actions/runs/1", + report: report(), + }, +}); + +test("the official registry matches the existing capability matrix", () => + validateStoreRegistry(registry())); +test("registration accepts a passing independent provider or an experimental entry", () => { + const data = registry(); + data.stores.push(community()); + assert.equal(validateStoreRegistry(data), data); + data.stores.at(-1).tier = "experimental"; + data.stores.at(-1).latestReport = null; + assert.equal(validateStoreRegistry(data), data); +}); +test("registered aliases and generated member names cannot collide", () => { + for (const mutate of [ + (store) => { + store.id = "play"; + }, + (store) => { + store.aliases = ["google"]; + }, + (store) => { + store.aliases = ["fixture", "fixture"]; + }, + (store) => { + store.id = undefined; + }, + (store) => { + store.aliases = [undefined]; + }, + (store) => { + store.id = "to-string"; + }, + ]) { + const data = registry(); + const store = community(); + mutate(store); + data.stores.push(store); + assert.throws( + () => validateStoreRegistry(data), + /Invalid|duplicate|collide/, + ); + } + const data = registry(); + data.stores.push(community(), { + ...community(), + id: "community.fixture", + aliases: [], + }); + assert.throws(() => validateStoreRegistry(data), /constants collide/); +}); +test("community coordinates and declared capabilities are validated", () => { + for (const mutate of [ + (store) => { + store.coordinates = "dev.example:provider:+"; + }, + (store) => { + store.coordinates = "io.github.hyochan.openiap:openiap-google:3.6.2"; + }, + (store) => { + store.capabilities = ["made-up"]; + }, + (store) => { + store.latestReport = null; + }, + (store) => { + store.latestReport.report.storeId = "different"; + }, + ]) { + const data = registry(); + const store = community(); + mutate(store); + data.stores.push(store); + assert.throws(() => validateStoreRegistry(data)); + } +}); +test("a passing claim cannot omit, skip or duplicate required behavior results", () => { + for (const mutate of [ + (value) => { + value.results.pop(); + }, + (value) => { + value.results[0].outcome = "not-applicable"; + }, + (value) => { + value.results.push({ ...value.results[0] }); + }, + (value) => { + value.scope.kind = "android-mapping"; + }, + (value) => { + value.scope.requiredBehaviors.pop(); + }, + (value) => { + value.capabilities = []; + }, + ]) { + const value = report(); + mutate(value); + assert.throws(() => validateProviderReport(value, ["pendingPurchases"])); + } +}); +test("a complete failed report remains experimental", () => { + const value = report(); + value.results[0].outcome = "fail"; + value.conformant = false; + assert.equal(validateProviderReport(value, ["pendingPurchases"]), false); + const data = registry(); + const store = community(); + store.tier = "experimental"; + store.latestReport.report = value; + data.stores.push(store); + validateStoreRegistry(data); +}); +test("declaring another capability makes its behavior mandatory", () => { + const value = report(); + value.capabilities.push("subscriptionBillingIssue"); + assert.throws( + () => validateProviderReport(value, value.capabilities), + /required suite/, + ); +}); +test("historical reports must use a published provider profile and its actual behaviors", () => { + const value = report(); + value.suiteVersion = "3.0.0"; + assert.throws( + () => validateProviderReport(value, value.capabilities), + /Unsupported provider suite major/, + ); + value.suiteVersion = SUITE_VERSION; + assert.equal( + validateProviderReport(value, value.capabilities, { + suiteVersion: "5.0.0", + }), + true, + ); + value.scope.requiredBehaviors = ["bogus"]; + value.results = [{ id: "bogus", outcome: "pass" }]; + assert.throws( + () => + validateProviderReport(value, value.capabilities, { + suiteVersion: "5.0.0", + }), + /required suite/, + ); +}); +test("an older passing major becomes unmaintained after the grace window", () => { + const store = community(); + const options = { suiteVersion: "5.0.0", majorReleaseDate: "2026-10-01" }; + assert.equal( + maintenanceStatus(store, 90, { ...options, now: new Date("2026-10-15") }), + "outdated", + ); + assert.equal( + maintenanceStatus(store, 90, { ...options, now: new Date("2027-01-01") }), + "unmaintained", + ); + store.latestReport.report.suiteVersion = "5.0.0"; + assert.equal( + maintenanceStatus(store, 90, { ...options, now: new Date("2027-01-01") }), + "community", + ); +}); + +test("registered constants reject Dart keywords and Object members before generation", () => { + for (const id of [...DART_KEYWORDS] + .filter((name) => /^[a-z]+$/.test(name)) + .concat(["hash-code", "runtime-type", "no-such-method"])) { + const data = registry(); + data.stores.push({ + ...community(), + id, + tier: "experimental", + latestReport: null, + }); + assert.throws( + () => validateStoreRegistry(data), + /Invalid or duplicate|constants collide or are reserved/, + id, + ); + } +}); + +test("Apple providers use the shared behavior profile and fixed Swift package coordinates", () => { + const data = registry(); + const store = community(); + store.platform = "ios"; + store.coordinates = { + package: "https://github.com/example/provider", + product: "StoreProvider", + version: "1.0.0", + }; + const required = requiredProviderBehaviors( + store.capabilities, + undefined, + "ios", + ); + store.latestReport.report = { + ...report(), + scope: { + kind: "apple-provider", + complete: true, + requiredBehaviors: required, + }, + results: required.map((id) => ({ id, outcome: "pass" })), + }; + data.stores.push(store); + assert.equal(validateStoreRegistry(data), data); + store.latestReport.report.scope.kind = "android-provider"; + assert.throws(() => validateStoreRegistry(data), /apple-provider/); + store.latestReport.report.scope.kind = "apple-provider"; + for (const coordinate of [ + "dev.example:provider:1.0.0", + { ...store.coordinates, version: "+" }, + { ...store.coordinates, product: "" }, + { ...store.coordinates, package: undefined }, + ]) { + store.coordinates = coordinate; + assert.throws(() => validateStoreRegistry(data), /coordinates/); + } +}); + +test("an extra failing result vetoes an otherwise passing verdict", () => { + const extraFail = () => { + const value = report(); + value.results.push({ id: "extra-probe", outcome: "fail" }); + return value; + }; + const claimed = extraFail(); + claimed.conformant = true; + assert.throws( + () => validateProviderReport(claimed, ["pendingPurchases"]), + /verdict contradicts/, + ); + const honest = extraFail(); + honest.conformant = false; + assert.equal(validateProviderReport(honest, ["pendingPurchases"]), false); + const benign = report(); + benign.results.push({ id: "extra-probe", outcome: "not-applicable" }); + assert.equal(validateProviderReport(benign, ["pendingPurchases"]), true); +}); + +test("a wrong scope.complete flag contradicts the executed results", () => { + const incomplete = report(); + incomplete.results.pop(); + incomplete.conformant = false; + assert.throws( + () => validateProviderReport(incomplete, ["pendingPurchases"]), + /verdict contradicts/, + ); + const misflagged = report(); + misflagged.scope.complete = false; + assert.throws( + () => validateProviderReport(misflagged, ["pendingPurchases"]), + /verdict contradicts/, + ); +}); + +test("the maintenance window flips only after its full length", () => { + const store = community(); + const release = new Date("2026-10-01T00:00:00Z").getTime(); + const at = (days) => ({ + suiteVersion: "5.0.0", + majorReleaseDate: "2026-10-01T00:00:00Z", + now: new Date(release + days * 86_400_000), + }); + assert.equal(maintenanceStatus(store, 90, at(60)), "outdated"); + assert.equal(maintenanceStatus(store, 90, at(89)), "outdated"); + assert.equal(maintenanceStatus(store, 90, at(90)), "outdated"); + assert.equal(maintenanceStatus(store, 90, at(91)), "unmaintained"); +}); + +test("a passing report cannot claim the experimental tier", () => { + const data = registry(); + const experimental = community(); + experimental.tier = "experimental"; + data.stores.push(experimental); + assert.throws(() => validateStoreRegistry(data), /Tier disagrees/); + const failing = report(); + failing.results[0].outcome = "fail"; + failing.conformant = false; + const failed = registry(); + const store = community(); + store.latestReport.report = failing; + failed.stores.push(store); + assert.throws(() => validateStoreRegistry(failed), /Tier disagrees/); +}); + +test("one community store id can bind to both platforms without duplicate metadata", () => { + const store = community(); + const android = { + coordinates: store.coordinates, + capabilities: store.capabilities, + latestReport: store.latestReport, + }; + const appleReport = { ...report([]), capabilities: [] }; + const required = requiredProviderBehaviors([], undefined, "ios"); + appleReport.scope = { + kind: "apple-provider", + complete: true, + requiredBehaviors: required, + }; + appleReport.results = required.map((id) => ({ id, outcome: "pass" })); + const ios = { + coordinates: { + package: "https://github.com/example/provider", + product: "StoreProvider", + version: "1.0.0", + }, + capabilities: [], + latestReport: { + url: "https://github.com/example/provider/actions/runs/2", + report: appleReport, + }, + }; + delete store.platform; + delete store.coordinates; + delete store.capabilities; + delete store.latestReport; + store.bindings = { android, ios }; + const data = registry(); + data.stores.push(store); + assert.equal(validateStoreRegistry(data), data); + assert.equal(maintenanceStatus(store, 90), "community"); + store.bindings.ios.latestReport = null; + store.tier = "experimental"; + assert.equal(validateStoreRegistry(data), data); + store.tier = "community"; + assert.throws(() => validateStoreRegistry(data), /Tier disagrees/); + store.tier = "experimental"; + store.bindings.ios.latestReport = ios.latestReport; + store.platform = "ios"; + assert.throws(() => validateStoreRegistry(data), /two owners/); +}); diff --git a/scripts/sync-release-generated.sh b/scripts/sync-release-generated.sh index f0169d1d9..1ca6e7bfa 100755 --- a/scripts/sync-release-generated.sh +++ b/scripts/sync-release-generated.sh @@ -16,7 +16,9 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "$REPO_ROOT" +bun install --frozen-lockfile --ignore-scripts ./scripts/sync-versions.sh +node scripts/generate-store-registry.mjs (cd packages/conformance && bun run generate:ids) @@ -26,7 +28,11 @@ git add \ openiap-versions.json \ packages/docs/openiap-versions.json \ packages/apple/Sources/OpenIapGeneratedVersion.swift \ + libraries/maui-iap/src/OpenIap.Maui/buildTransitive/OpenIap.Maui.props \ packages/conformance/src/spec/generated-spec.mjs \ + packages/conformance/android/src/main/kotlin/dev/hyo/openiap/conformance/ConformanceBehaviors.kt \ + packages/conformance/apple/Sources/ProviderBehaviors.swift \ + packages/docs/src/generated/store-registry.json \ packages/docs/src/generated/version-metadata.json \ packages/docs/public/llms.txt \ packages/docs/public/llms-full.txt \ diff --git a/scripts/sync-versions.sh b/scripts/sync-versions.sh index c588b3e9d..f2055006a 100755 --- a/scripts/sync-versions.sh +++ b/scripts/sync-versions.sh @@ -322,6 +322,8 @@ import re import sys path, store_path, play_billing, amazon_appstore_sdk, horizon_billing_compatibility, horizon_platform_kotlin, gson = sys.argv[1:] +import json +core_version = json.load(open("openiap-versions.json", encoding="utf-8"))["google"] text = open(path, encoding="utf-8").read() preserved_property_names = [ "MauiPlayServicesBaseVersion", @@ -332,6 +334,8 @@ preserved_property_names = [ "MauiGoogleGsonNuGetVersion", "MauiAndroidXActivityVersion", "MauiAndroidXCollectionVersion", + "MauiComposeAnnotationVersion", + "MauiAndroidXWindowVersion", "MauiAndroidXFragmentVersion", "MauiAndroidXFragmentKtxVersion", "MauiAndroidXLifecycleVersion", @@ -361,6 +365,8 @@ content = f""" {preserved["MauiGoogleGsonNuGetVersion"]} {preserved["MauiAndroidXActivityVersion"]} {preserved["MauiAndroidXCollectionVersion"]} + {preserved["MauiComposeAnnotationVersion"]} + {preserved["MauiAndroidXWindowVersion"]} {preserved["MauiAndroidXFragmentVersion"]} {preserved["MauiAndroidXFragmentKtxVersion"]} {preserved["MauiAndroidXLifecycleVersion"]} @@ -374,9 +380,10 @@ open(path, "w", encoding="utf-8").write(content) # The app build reads these from the package, so they live beside its targets. store_content = f""" - + <_OpenIapMauiPropsImported>true + {core_version} {play_billing} {amazon_appstore_sdk} {horizon_billing_compatibility} diff --git a/security/ASSURANCE.md b/security/ASSURANCE.md index 64cfff187..5239b4f56 100644 --- a/security/ASSURANCE.md +++ b/security/ASSURANCE.md @@ -25,7 +25,9 @@ workflow token do not recursively start CodeQL, so they inherit source-analysis evidence from the reviewed source commit rather than claiming a second scan: - no unaccepted advisory in any committed dependency lock graph; temporary - upstream-unpatched exceptions must be scoped, justified, and expiring; + exceptions for advisories the lock does not clear yet (no fixed release, + a fix outside the dependents' ranges, or a clearing dependency bump that + has not been tested yet) must be scoped, justified, and expiring; - immutable external GitHub Action references and explicit workflow token permissions; - CodeQL analysis of the hosted/web, C#, workflow, JVM Kotlin core/wrapper, and diff --git a/security/README.md b/security/README.md index ac2f46b2d..a753153a3 100644 --- a/security/README.md +++ b/security/README.md @@ -103,6 +103,19 @@ the kit Dockerfile, and the React Native CocoaPods toolchain: direct dependency contract; a toolchain resolver export can add transitive entries for a consuming application. +## Temporary dependency exceptions + +An exception is an advisory the lock does not clear yet (no fixed +release, a fix outside the dependents' version ranges, or a clearing +dependency bump that has not been tested yet). Each exception lives in the +`osv-scanner.toml` next to its lock, with a reason and an `ignoreUntil` +date — the first expired day. `audit:dependencies` fails on an expired +or unused exception, and each exception is rechecked by its date: + +```sh +git grep -n ignoreUntil -- '*osv-scanner.toml' +``` + ## GitHub dependency graph GitHub does not parse `bun.lock` directly. The @@ -296,9 +309,12 @@ Every pull request installs all committed Bun locks plus the React Native Yarn and Ruby locks without mutation. It then runs Bun's advisory audit across all Bun graphs and OSV-Scanner across all eight locks. Unaccepted findings fail the build. -Upstream-unpatched, build-only findings may be accepted only in the owning -project's `osv-scanner.toml` with a reason and expiry; expired or stale -exceptions fail the dependency audit, and OSV enforces the same expiry. The IAPKit +Temporary exceptions for advisories the lock does not clear yet (no +fixed release, a fix outside the dependents' ranges, or a clearing +dependency bump that has not been tested yet) may be accepted only in the +owning project's `osv-scanner.toml` with a reason and expiry; expired +or stale exceptions fail the dependency audit, and OSV enforces the same +expiry. The IAPKit deployment repeats the Bun gate. The submitted dependency graph provides hosted Dependabot monitoring, while CodeQL covers source and workflow vulnerabilities in its configured languages. diff --git a/specs/client/CONVENTION.md b/specs/client/CONVENTION.md index dec56d458..3417dd8be 100644 --- a/specs/client/CONVENTION.md +++ b/specs/client/CONVENTION.md @@ -29,6 +29,9 @@ directory's `schema-files.mjs` or merge the two generation pipelines. parallel field lists. - `generated-sync-manifest.mjs` is the only generated source/target path map used by platform sync and commit-time drift checks. +- `src/store-registry.json` owns provider ids, aliases, tiers, and reports; see + [02-architecture.md](../../knowledge/internal/02-architecture.md#store-providers) + and regenerate consumers with `bun run stores:generate` from the repo root. - `src/type.graphql`: common cross‑platform SDL only. - `src/type-ios.graphql`: iOS‑specific SDL only. - `src/type-android.graphql`: Android‑specific SDL only. diff --git a/specs/client/README.md b/specs/client/README.md index 454ce32a6..bd58ec40f 100644 --- a/specs/client/README.md +++ b/specs/client/README.md @@ -21,7 +21,10 @@ npm install @hyodotdev/openiap-client-protocol ``` ```ts -import { ErrorCode, type PurchaseError } from '@hyodotdev/openiap-client-protocol'; +import { + ErrorCode, + type PurchaseError, +} from "@hyodotdev/openiap-client-protocol"; function isCanceled(error: PurchaseError): boolean { return error.code === ErrorCode.UserCancelled; @@ -33,10 +36,26 @@ export provides the portable IAPKit client helper. Generated native types are available at `/swift`, `/kotlin`, `/dart`, `/gdscript`, and `/csharp`. For a working purchase integration, choose a [framework SDK](https://openiap.dev/languages). -This package's npm version *is* the Client Protocol version. It is mirrored +This package's npm version _is_ the Client Protocol version. It is mirrored into `openiap-versions.json` as `clientProtocol`, alongside the independent `openiap-apple` and `openiap-google` native package versions. +## Common store provider contract + +Apple and Android providers share `StoreProviderDescriptor`, frozen `IapStore` +values, concrete `storeId` identity, purchase lifecycle and capability rules. +A new store uses `store = unknown` with its stable provider id. External +providers are selected with a community id; a provider that serves an existing +store reports that store's canonical `storeId` and legacy `store` value in its +purchases. Platform bindings preserve `PurchaseIOS` or `PurchaseAndroid`; `request.apple` and +`request.google` select the corresponding platform arguments. + +Factories declare the native core and Client Protocol versions used to build +the provider. An incompatible binding fails at connection. One optional registry +entry can carry both platform bindings and their conformance reports. +See the [provider guide](https://openiap.dev/docs/guides/store-providers) for +native factories, package selection and independent verification. + ## Develop the specification The generator runs from a checkout of the OpenIAP repository; it is not included diff --git a/specs/client/codegen.ts b/specs/client/codegen.ts index 2e7484bde..e76da65be 100644 --- a/specs/client/codegen.ts +++ b/specs/client/codegen.ts @@ -1,3 +1,4 @@ +import { renderStoreIds, renderStoreIdentityResolver } from './codegen/core/store-ids.js'; import { CodegenConfig } from '@graphql-codegen/cli'; import { generatedFileHeader } from './codegen/core/generated-header.js'; import { GRAPHQL_TO_TYPESCRIPT } from './codegen/core/utils.js'; @@ -13,7 +14,7 @@ const config: CodegenConfig = { plugins: [ { add: { - content: [...generatedFileHeader(), ''].join('\n'), + content: [...generatedFileHeader(), '', renderStoreIds('typescript'), renderStoreIdentityResolver('typescript')].join('\n'), }, }, 'typescript', diff --git a/specs/client/codegen/core/dart-keywords.mjs b/specs/client/codegen/core/dart-keywords.mjs new file mode 100644 index 000000000..64f24971a --- /dev/null +++ b/specs/client/codegen/core/dart-keywords.mjs @@ -0,0 +1,65 @@ +export const DART_KEYWORDS = new Set([ + "abstract", + "as", + "assert", + "async", + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "covariant", + "default", + "deferred", + "do", + "dynamic", + "else", + "enum", + "export", + "extends", + "extension", + "external", + "factory", + "false", + "final", + "finally", + "for", + "Function", + "get", + "hide", + "if", + "implements", + "import", + "in", + "interface", + "is", + "late", + "library", + "mixin", + "new", + "null", + "on", + "operator", + "part", + "required", + "rethrow", + "return", + "set", + "show", + "static", + "super", + "switch", + "sync", + "this", + "throw", + "true", + "try", + "typedef", + "var", + "void", + "while", + "with", + "yield", +]); diff --git a/specs/client/codegen/core/store-ids.ts b/specs/client/codegen/core/store-ids.ts new file mode 100644 index 000000000..0b0c6cb87 --- /dev/null +++ b/specs/client/codegen/core/store-ids.ts @@ -0,0 +1,171 @@ +import registry from "../../src/store-registry.json"; +import { SWIFT_KEYWORDS } from "./utils.js"; + +import type { IRField } from "./types.js"; + +export const LEGACY_STORE_IDS = { + Apple: "apple", + Google: "play", + Horizon: "horizon", + Amazon: "amazon", +} as const; + +export function hasStoreIdentity(fields: IRField[]): boolean { + return ( + fields.some( + ({ name, type }) => + name === "store" && type.name === "IapStore" && !type.nullable, + ) && + fields.some( + ({ name, type }) => + name === "storeId" && type.name === "String" && !type.nullable, + ) + ); +} + +type StoreIdLanguage = + | "typescript" + | "kotlin" + | "swift" + | "dart" + | "gdscript" + | "csharp"; +const member = (id: string) => + id + .split(/[._-]/) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); + +/** Registry ids are additive constants; they never extend IapStore. */ +export function renderStoreIds(language: StoreIdLanguage): string { + const rows = registry.stores + .map(({ id }) => { + const name = member(id); + switch (language) { + case "typescript": + return ` ${name}: '${id}',`; + case "kotlin": + return ` const val ${name} = "${id}"`; + case "swift": + return ` public static let ${SWIFT_KEYWORDS.has(name) ? `\`${name}\`` : name} = "${id}"`; + case "dart": + return ` static const String ${name[0].toLowerCase() + name.slice(1)} = '${id}';`; + case "gdscript": + return `\tconst ${id.replace(/[._-]/g, "_").toUpperCase()} = "${id}"`; + case "csharp": + return ` public const string ${name} = "${id}";`; + } + }) + .join("\n"); + switch (language) { + case "typescript": + return `export const StoreIds = {\n${rows}\n} as const;\n`; + case "kotlin": + return `public object StoreIds {\n${rows}\n}\n`; + case "swift": + return `public enum StoreIds {\n${rows}\n}\n`; + case "dart": + return `abstract final class StoreIds {\n${rows}\n}\n`; + case "gdscript": + return `class StoreIds:\n${rows}\n`; + case "csharp": + return `public static class StoreIds\n{\n${rows}\n}\n`; + } +} + +/** Decode official purchases persisted before storeId became required. */ +export function renderStoreIdentityResolver(language: StoreIdLanguage): string { + const entries = Object.entries(LEGACY_STORE_IDS); + const reserved = [ + "auto", + "none", + "unknown", + "apple", + "play", + "google", + "horizon", + "amazon", + ]; + const pattern = "[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*"; + switch (language) { + case "kotlin": + return `private fun resolveStoreId(store: IapStore, value: Any?): String { + val official = when (store) { +${entries.map(([name, id]) => ` IapStore.${name} -> "${id}"`).join("\n")} + IapStore.Unknown -> null + } + require(value == null || value is String) { "storeId must be a string" } + val id = value as String? ?: official + require(id != null && if (official != null) id == official else id.matches(Regex("${pattern}")) && id !in setOf(${reserved.map((id) => `"${id}"`).join(", ")})) { "Invalid store identity" } + return id +} +`; + case "swift": + return `private func resolveStoreId(_ store: IapStore, _ value: String?) -> String? { + let official: String? + switch store { +${entries.map(([name, id]) => ` case .${name.toLowerCase()}: official = "${id}"`).join("\n")} + case .unknown: official = nil + } + guard let id = value ?? official else { return nil } + if let official { return id == official ? id : nil } + guard id.range(of: "^${pattern}$", options: .regularExpression) == id.startIndex.. `"${id}"`).join(", ")}].contains(id) else { return nil } + return id +} +`; + case "dart": + return `String _resolveStoreId(IapStore store, dynamic value) { + final official = switch (store) { +${entries.map(([name, id]) => ` IapStore.${name} => '${id}',`).join("\n")} + IapStore.Unknown => null, + }; + final id = value ?? official; + if (id is! String || (official != null ? id != official : RegExp(r'^${pattern}\$').firstMatch(id)?.end != id.length || const {${reserved.map((id) => `'${id}'`).join(", ")}}.contains(id))) { + throw const FormatException('Invalid store identity'); + } + return id; +} +`; + case "gdscript": + return `static func resolve_store_id(store: Variant, value: Variant) -> Variant: + var official: Variant = {${entries.map(([name, id]) => `IapStore.${name.toUpperCase()}: "${id}"`).join(", ")}}.get(store) + var id: Variant = value if value != null else official + if not id is String: + return null + if official != null: + return id if id == official else null + var pattern = RegEx.new() + pattern.compile("^${pattern}$") + return id if pattern.search(id) != null and pattern.search(id).get_string() == id and not id in [${reserved.map((id) => `"${id}"`).join(", ")}] else null +`; + case "csharp": + return `internal static class StoreIdentity +{ + internal static string Resolve(IapStore store, string? value) + { + var official = store switch + { +${entries.map(([name, id]) => ` IapStore.${name} => "${id}",`).join("\n")} + _ => null, + }; + var id = value ?? official; + if (id is null || (official is not null ? id != official : !System.Text.RegularExpressions.Regex.IsMatch(id, @"\\A${pattern}\\z") || id is ${reserved.map((id) => `"${id}"`).join(" or ")})) + throw new JsonException("Invalid store identity."); + return id; + } +} +`; + case "typescript": + return `export function resolveStoreId(value: unknown, store: IapStore): string { + const officialIds: Partial> = {${entries.map(([name, id]) => `'${name.toLowerCase()}': '${id}'`).join(", ")}}; + const official = officialIds[store]; + const id = value ?? official; + if (typeof id !== 'string' || (official != null ? id !== official : store !== 'unknown' || id.match(/^${pattern}$/)?.[0] !== id || [${reserved.map((id) => `'${id}'`).join(", ")}].includes(id))) { + throw new Error('Invalid store identity'); + } + return id; +} +`; + } +} diff --git a/specs/client/codegen/core/utils.ts b/specs/client/codegen/core/utils.ts index 9c72636b7..0faca4db3 100644 --- a/specs/client/codegen/core/utils.ts +++ b/specs/client/codegen/core/utils.ts @@ -182,6 +182,7 @@ export const SWIFT_KEYWORDS = new Set([ 'try', 'Any', 'Protocol', + 'Type', ]); export const KOTLIN_KEYWORDS = new Set([ @@ -214,71 +215,7 @@ export const KOTLIN_KEYWORDS = new Set([ 'while', ]); -export const DART_KEYWORDS = new Set([ - 'abstract', - 'as', - 'assert', - 'async', - 'await', - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'covariant', - 'default', - 'deferred', - 'do', - 'dynamic', - 'else', - 'enum', - 'export', - 'extends', - 'extension', - 'external', - 'factory', - 'false', - 'final', - 'finally', - 'for', - 'Function', - 'get', - 'hide', - 'if', - 'implements', - 'import', - 'in', - 'interface', - 'is', - 'late', - 'library', - 'mixin', - 'new', - 'null', - 'on', - 'operator', - 'part', - 'required', - 'rethrow', - 'return', - 'set', - 'show', - 'static', - 'super', - 'switch', - 'sync', - 'this', - 'throw', - 'true', - 'try', - 'typedef', - 'var', - 'void', - 'while', - 'with', - 'yield', -]); +export { DART_KEYWORDS } from './dart-keywords.mjs'; export const GDSCRIPT_KEYWORDS = new Set([ 'if', diff --git a/specs/client/codegen/plugins/csharp.ts b/specs/client/codegen/plugins/csharp.ts index 449477a8b..4d95d031a 100644 --- a/specs/client/codegen/plugins/csharp.ts +++ b/specs/client/codegen/plugins/csharp.ts @@ -19,6 +19,7 @@ * mirroring the Kotlin sealed-interface pattern. */ +import { renderStoreIds, renderStoreIdentityResolver, hasStoreIdentity } from '../core/store-ids.js'; import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; import { generatedFileHeader } from '../core/generated-header.js'; import type { @@ -200,6 +201,8 @@ export class CSharpPlugin extends CodegenPlugin { } this.generateHeader(); + this.emit(renderStoreIds('csharp')); + this.emit(renderStoreIdentityResolver('csharp')); if (schema.enums.length > 0) { this.addSection('Enums'); @@ -584,15 +587,37 @@ export class CSharpPlugin extends CodegenPlugin { this.emitDoc(irObject.description); const baseTypes = this.computeBaseTypes(irObject); + if (hasStoreIdentity(sortedFields)) baseTypes.push('IJsonOnDeserialized'); const inheritance = baseTypes.length > 0 ? ` : ${baseTypes.join(', ')}` : ''; this.emit(`public sealed record ${irObject.name}${inheritance}`); this.emit('{'); - this.emitProperties(sortedFields, this.inheritedUnionFieldNames(irObject)); + this.emitProperties(sortedFields, this.inheritedUnionFieldNames(irObject), false, hasStoreIdentity(sortedFields)); + if (hasStoreIdentity(sortedFields)) { + this.emit(' void IJsonOnDeserialized.OnDeserialized() => _storeId = StoreIdentity.Resolve(Store, _storeId);'); + this.emitStoreIdentityEquality(irObject.name, sortedFields); + } this.emit('}'); this.emit(''); this.emitNullableJsonConverter(irObject.name); } + // Compare resolved identity so legacy values stay equal after JSON round trips. + private emitStoreIdentityEquality(name: string, fields: IRField[]): void { + this.emit(` public bool Equals(${name}? other) => other is not null`); + fields.forEach((field, index) => { + const property = this.fieldNameCase(field.name); + const end = index === fields.length - 1 ? ';' : ''; + this.emit(` && EqualityComparer<${this.propertyType(field.type)}>.Default.Equals(${property}, other.${property})${end}`); + }); + this.emit(' public override int GetHashCode()'); + this.emit(' {'); + this.emit(' var hash = new HashCode();'); + this.emit(` hash.Add(typeof(${name}));`); + for (const field of fields) this.emit(` hash.Add(${this.fieldNameCase(field.name)});`); + this.emit(' return hash.ToHashCode();'); + this.emit(' }'); + } + private computeBaseTypes(irObject: IRObject): string[] { // Records can only inherit from a single base record; pick the first // union as the base, then implement interfaces. Subsequent unions (rare) @@ -626,7 +651,7 @@ export class CSharpPlugin extends CodegenPlugin { return names; } - private emitProperties(fields: IRField[], inheritedFields = new Set(), isInputContext = false): void { + private emitProperties(fields: IRField[], inheritedFields = new Set(), isInputContext = false, storeIdentity = false): void { fields.forEach((field) => { this.emitDoc(field.description, ' '); this.emitDeprecation(field.description, ' '); @@ -635,6 +660,7 @@ export class CSharpPlugin extends CodegenPlugin { const propName = this.fieldNameCase(field.name); const jsonName = field.name; const overrideModifier = inheritedFields.has(field.name) ? 'override ' : ''; + if (storeIdentity && field.name === 'storeId') this.emit(' private string? _storeId;'); this.emit(` [JsonPropertyName("${jsonName}")]`); if (isInputContext && field.type.kind === 'enum') { const converter = field.type.nullable ? 'StrictNullableEnumJsonConverter' : 'StrictEnumJsonConverter'; @@ -662,10 +688,10 @@ export class CSharpPlugin extends CodegenPlugin { this.emit(` [JsonConverter(typeof(${field.type.elementType.name}NullableElementListJsonConverter))]`); } - // Required vs. nullable — non-nullable scalars/objects get the C# - // `required` modifier so callers must initialize them; nullable - // properties default to null. - if (field.type.nullable) { + // Legacy official identity can be inferred; other non-null fields remain required. + if (storeIdentity && field.name === 'storeId') { + this.emit(` public ${overrideModifier}string StoreId { get => StoreIdentity.Resolve(Store, _storeId); init => _storeId = value; }`); + } else if (field.type.nullable) { const defaultValue = this.buildDefaultValueExpression(field); const initializer = defaultValue ? ` = ${defaultValue};` : ''; this.emit(` public ${overrideModifier}${propType} ${propName} { get; init; }${initializer}`); diff --git a/specs/client/codegen/plugins/dart.ts b/specs/client/codegen/plugins/dart.ts index ab0e62493..77297a39e 100644 --- a/specs/client/codegen/plugins/dart.ts +++ b/specs/client/codegen/plugins/dart.ts @@ -5,6 +5,7 @@ * Uses the IR (Intermediate Representation) for maintainable code generation. */ +import { renderStoreIds, renderStoreIdentityResolver, hasStoreIdentity } from '../core/store-ids.js'; import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; import { generatedFileHeader } from '../core/generated-header.js'; import type { @@ -73,6 +74,8 @@ export class DartPlugin extends CodegenPlugin { this.lines = []; this.generateHeader(); + this.emit(renderStoreIds('dart')); + if (schema.enums.some(item => item.name === 'IapStore')) this.emit(renderStoreIdentityResolver('dart')); // Enums if (schema.enums.length > 0) { @@ -302,9 +305,10 @@ export class DartPlugin extends CodegenPlugin { // fromJson factory this.emit(''); this.emit(` factory ${irObject.name}.fromJson(Map json) {`); + if (hasStoreIdentity(sortedFields)) this.emit(' final store = IapStore.fromJson(json[\'store\'] as String);'); this.emit(` return ${irObject.name}(`); for (const field of sortedFields) { - const jsonExpr = this.buildFromJsonExpression(field.type, `json['${field.name}']`); + const jsonExpr = hasStoreIdentity(sortedFields) && field.name === 'storeId' ? "_resolveStoreId(store, json['storeId'])" : hasStoreIdentity(sortedFields) && field.name === 'store' ? 'store' : this.buildFromJsonExpression(field.type, `json['${field.name}']`); this.emit(` ${this.escapeKeyword(field.name)}: ${jsonExpr},`); } if (needsAlternativeBilling) { diff --git a/specs/client/codegen/plugins/gdscript.ts b/specs/client/codegen/plugins/gdscript.ts index 1c04b65c3..4ede7859c 100644 --- a/specs/client/codegen/plugins/gdscript.ts +++ b/specs/client/codegen/plugins/gdscript.ts @@ -5,6 +5,7 @@ * Uses the IR (Intermediate Representation) for maintainable code generation. */ +import { renderStoreIds, renderStoreIdentityResolver, hasStoreIdentity } from '../core/store-ids.js'; import { basename } from 'node:path'; import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; import { generatedFileHeader } from '../core/generated-header.js'; @@ -187,6 +188,8 @@ export class GDScriptPlugin extends CodegenPlugin { this.lines = []; this.generateHeader(); + this.emit(renderStoreIds('gdscript')); + if (schema.enums.some(item => item.name === 'IapStore')) this.emit(renderStoreIdentityResolver('gdscript')); // Enums this.emit('# ============================================================================'); @@ -471,7 +474,13 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit(`\t\tvar obj = ${irObject.name}.new()`); for (const field of fields) { const fieldName = this.getGdscriptFieldName(field.name, irObject.name); - this.generateFromDictField(field, fieldName); + if (!hasStoreIdentity(fields) || field.name !== 'storeId') this.generateFromDictField(field, fieldName); + } + if (hasStoreIdentity(fields)) { + this.emit('\t\tvar store_id = _Types.resolve_store_id(obj.store, data.get("storeId"))'); + this.emit('\t\tif store_id == null:'); + this.emit('\t\t\treturn null'); + this.emit('\t\tobj.store_id = store_id'); } this.emit('\t\treturn obj'); diff --git a/specs/client/codegen/plugins/kotlin.ts b/specs/client/codegen/plugins/kotlin.ts index 3d346778e..7c2217b4b 100644 --- a/specs/client/codegen/plugins/kotlin.ts +++ b/specs/client/codegen/plugins/kotlin.ts @@ -4,6 +4,7 @@ * Generates Kotlin data classes with JSON serialization from GraphQL schema. */ +import { renderStoreIds, renderStoreIdentityResolver, hasStoreIdentity } from '../core/store-ids.js'; import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; import { generatedFileHeader } from '../core/generated-header.js'; import type { @@ -28,9 +29,11 @@ import { } from '../core/utils.js'; interface CompatibleDataClassShape { + preserveExtraValues?: boolean; primaryFields: string[]; extraFields: string[]; legacyExtraFieldCounts?: number[]; + legacyRequiredExtraFieldCounts?: number[]; } const COMPATIBLE_DATA_CLASS_SHAPES: Record = { @@ -43,9 +46,11 @@ const COMPATIBLE_DATA_CLASS_SHAPES: Record = { extraFields: ['productDetailsAndroid', 'originalExternalTransactionId'], }, RequestVerifyPurchaseWithIapkitResult: { + preserveExtraValues: true, primaryFields: ['isValid', 'state', 'store'], - extraFields: ['clientPayload', 'productId', 'environment'], - legacyExtraFieldCounts: [2], + extraFields: ['clientPayload', 'productId', 'environment', 'storeId'], + legacyExtraFieldCounts: [2, 3], + legacyRequiredExtraFieldCounts: [3], }, }; @@ -119,6 +124,8 @@ export class KotlinPlugin extends CodegenPlugin { // Header this.generateHeader(); + this.emit(renderStoreIds('kotlin')); + if (schema.enums.some(item => item.name === 'IapStore')) this.emit(renderStoreIdentityResolver('kotlin')); // Enums if (schema.enums.length > 0) { @@ -334,20 +341,55 @@ export class KotlinPlugin extends CodegenPlugin { this.emit(''); this.emit(' companion object {'); this.emit(` fun fromJson(json: Map): ${irObject.name} {`); + if (hasStoreIdentity(irObject.fields)) this.emit(' val store = IapStore.fromJson(json["store"] as? String ?: "")'); this.emit(` return ${irObject.name}(`); const rejectMissingStrictEnums = this.typeHasRequiredEnumWithoutUnknown(irObject.name, this.schema); + // Descriptor identities must be present; blanks hide a broken provider. + const isStrictDescriptor = irObject.name === 'StoreProviderDescriptor'; for (const field of sortedFields) { const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); - const expression = this.buildFromJsonExpression( - field.type, - `json["${field.name}"]`, - false, - false, - this.buildDefaultValueExpression(field), - rejectMissingStrictEnums, - ); + let expression: string; + if (hasStoreIdentity(irObject.fields) && field.name === 'storeId') { + expression = 'resolveStoreId(store, json["storeId"])'; + } else if (hasStoreIdentity(irObject.fields) && field.name === 'store') { + expression = 'store'; + } else if ( + isStrictDescriptor && + !field.type.nullable && + !this.hasSchemaDefault(field) && + field.type.kind === 'scalar' && + (field.type.name === 'String' || field.type.name === 'ID') + ) { + expression = `(json["${field.name}"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank ${field.name} for ${irObject.name}")`; + } else if ( + isStrictDescriptor && + !field.type.nullable && + !this.hasSchemaDefault(field) && + field.type.kind === 'list' + ) { + const element = this.buildFromJsonExpression( + field.type.elementType!, + 'it', + true, + false, + undefined, + rejectMissingStrictEnums, + false, + ); + const mapFn = field.type.elementType!.nullable ? 'map' : 'mapNotNull'; + expression = `(json["${field.name}"] as? List<*>)?.${mapFn} { ${element} } ?: throw IllegalArgumentException("Missing ${field.name} for ${irObject.name}")`; + } else { + expression = this.buildFromJsonExpression( + field.type, + `json["${field.name}"]`, + false, + false, + this.buildDefaultValueExpression(field), + rejectMissingStrictEnums, + ); + } this.emit(` ${propertyName} = ${expression},`); } @@ -383,31 +425,68 @@ export class KotlinPlugin extends CodegenPlugin { if (primaryFields.length + extraFields.length !== irObject.fields.length) { throw new Error(`${irObject.name} compatibility shape is incomplete`); } - if (extraFields.some((value) => !value.type.nullable)) { + if (extraFields.some((value) => !value.type.nullable && value.name !== 'storeId')) { throw new Error(`${irObject.name} compatibility fields must be nullable`); } + const hasIdentity = hasStoreIdentity(irObject.fields); + if (hasIdentity && !shape.preserveExtraValues) { + throw new Error(`${irObject.name} store identity needs a hand-rolled copy`); + } + const unknownError = `${irObject.name} with store Unknown requires an explicit valid storeId`; + const officialIds = 'IapStore.Apple -> StoreIds.Apple; IapStore.Google -> StoreIds.Play; IapStore.Horizon -> StoreIds.Horizon; IapStore.Amazon -> StoreIds.Amazon'; this.generateDocComment(irObject.description); this.generateDeprecationAnnotation(irObject.description); - this.emit(`public data class ${irObject.name}(`); - primaryFields.forEach((value, index) => { - this.generateDocComment(value.description, ' '); - this.generateDeprecationAnnotation(value.description, ' '); - const suffix = index === primaryFields.length - 1 ? '' : ','; - const defaultValue = this.getObjectFieldDefault(value); - this.emit(` val ${value.name}: ${this.getPropertyType(value.type)}${defaultValue}${suffix}`); - }); - this.emit(') {'); + if (hasIdentity) { + this.emit(`public class ${irObject.name} private constructor(`); + for (const value of primaryFields) { + this.generateDocComment(value.description, ' '); + this.generateDeprecationAnnotation(value.description, ' '); + const defaultValue = this.getObjectFieldDefault(value); + this.emit(` val ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); + } + this.emit(' explicitStoreId: String?,'); + this.emit(') {'); + } else { + this.emit(`public ${shape.preserveExtraValues ? 'class' : 'data class'} ${irObject.name}(`); + primaryFields.forEach((value, index) => { + this.generateDocComment(value.description, ' '); + this.generateDeprecationAnnotation(value.description, ' '); + const suffix = index === primaryFields.length - 1 ? '' : ','; + const defaultValue = this.getObjectFieldDefault(value); + this.emit(` val ${value.name}: ${this.getPropertyType(value.type)}${defaultValue}${suffix}`); + }); + this.emit(') {'); + } this.emit(''); for (const value of extraFields) { this.generateDocComment(value.description, ' '); this.generateDeprecationAnnotation(value.description, ' '); - this.emit(` var ${value.name}: ${this.getPropertyType(value.type)} = null`); + const initialValue = value.name === 'storeId' + ? `explicitStoreId?.let { resolveStoreId(store, it) } ?: when (store) { ${officialIds}; IapStore.Unknown -> throw IllegalArgumentException("${unknownError}") }` + : 'null'; + this.emit(` var ${value.name}: ${this.getPropertyType(value.type)} = ${initialValue}`); this.emit(' private set'); this.emit(''); } + if (hasIdentity) { + this.emit(' constructor('); + for (const value of primaryFields) { + const defaultValue = this.getObjectFieldDefault(value); + this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); + } + this.emit(' ) : this('); + for (const value of primaryFields) { + this.emit(` ${value.name} = ${value.name},`); + } + this.emit(' explicitStoreId = null,'); + this.emit(' ) {'); + this.emit(' }'); + this.emit(''); + } + const constructorExtraFieldCounts = [ ...new Set([...(shape.legacyExtraFieldCounts ?? []), extraFields.length]), ]; @@ -418,33 +497,81 @@ export class KotlinPlugin extends CodegenPlugin { const constructorExtraFields = extraFields.slice(0, extraFieldCount); const isCurrentConstructor = extraFieldCount === extraFields.length; const hasLegacyConstructor = (shape.legacyExtraFieldCounts?.length ?? 0) > 0; + const passesStoreId = hasIdentity && constructorExtraFields.some((value) => value.name === 'storeId'); this.emit(' constructor('); for (const value of primaryFields) { const defaultValue = this.getObjectFieldDefault(value); this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); } constructorExtraFields.forEach((value, index) => { - const defaultValue = index === 0 || (isCurrentConstructor && hasLegacyConstructor) ? '' : ' = null'; + const hasRequiredExtraField = constructorExtraFields.some((field) => !field.type.nullable); + const required = !value.type.nullable || + shape.legacyRequiredExtraFieldCounts?.includes(extraFieldCount) || + (!hasRequiredExtraField && (index === 0 || (isCurrentConstructor && hasLegacyConstructor))); + const defaultValue = required ? '' : ' = null'; this.emit(` ${value.name}: ${this.getPropertyType(value.type)}${defaultValue},`); }); this.emit(' ) : this('); for (const value of primaryFields) { this.emit(` ${value.name} = ${value.name},`); } + if (hasIdentity) { + this.emit(` explicitStoreId = ${passesStoreId ? 'storeId' : 'null'},`); + } this.emit(' ) {'); for (const value of constructorExtraFields) { + if (hasIdentity && value.name === 'storeId') continue; this.emit(` this.${value.name} = ${value.name}`); } this.emit(' }'); this.emit(''); } + if (shape.preserveExtraValues) { + primaryFields.forEach((value, index) => this.emit(` operator fun component${index + 1}(): ${this.getPropertyType(value.type)} = ${value.name}`)); + this.emit(''); + if (hasIdentity) { + this.emit(' fun copy('); + for (const value of primaryFields) this.emit(` ${value.name}: ${this.getPropertyType(value.type)} = this.${value.name},`); + this.emit(' storeId: String? = null,'); + this.emit(` ): ${irObject.name} {`); + this.emit(` val resolvedStoreId = storeId?.let { resolveStoreId(store, it) } ?: if (store == this.store) this.storeId else when (store) { ${officialIds}; IapStore.Unknown -> throw IllegalArgumentException("${unknownError}") }`); + this.emit(` return ${irObject.name}(`); + for (const value of primaryFields) this.emit(` ${value.name} = ${value.name},`); + for (const value of extraFields) { + if (value.name === 'storeId') this.emit(' storeId = resolvedStoreId,'); + else this.emit(` ${value.name} = this.${value.name},`); + } + this.emit(' )'); + this.emit(' }'); + } else { + this.emit(' fun copy('); + for (const value of primaryFields) this.emit(` ${value.name}: ${this.getPropertyType(value.type)} = this.${value.name},`); + this.emit(` ): ${irObject.name} = ${irObject.name}(`); + for (const value of primaryFields) this.emit(` ${value.name} = ${value.name},`); + for (const value of extraFields) this.emit(` ${value.name} = this.${value.name},`); + this.emit(' )'); + } + this.emit(''); + const fields = [...primaryFields, ...extraFields]; + this.emit(` override fun equals(other: Any?): Boolean = other is ${irObject.name} &&`); + this.emit(` ${fields.map(value => `${value.name} == other.${value.name}`).join(' && ')}`); + this.emit(' override fun hashCode(): Int {'); + this.emit(' var result = 1'); + for (const value of fields) this.emit(` result = 31 * result + ${value.type.nullable ? `(${value.name}?.hashCode() ?: 0)` : `${value.name}.hashCode()`}`); + this.emit(' return result'); + this.emit(' }'); + this.emit(` override fun toString(): String = "${irObject.name}(${fields.map(value => `${value.name}=$${value.name}`).join(', ')})"`); + this.emit(''); + } + this.emit(' companion object {'); this.emit(` fun fromJson(json: Map): ${irObject.name} {`); + if (hasStoreIdentity(irObject.fields)) this.emit(' val store = IapStore.fromJson(json["store"] as? String ?: "")'); this.emit(` return ${irObject.name}(`); const rejectMissingStrictEnums = this.typeHasRequiredEnumWithoutUnknown(irObject.name, this.schema); for (const value of [...primaryFields, ...extraFields]) { - const expression = this.buildFromJsonExpression( + const expression = hasStoreIdentity(irObject.fields) && value.name === 'storeId' ? 'resolveStoreId(store, json["storeId"])' : hasStoreIdentity(irObject.fields) && value.name === 'store' ? 'store' : this.buildFromJsonExpression( value.type, `json["${value.name}"]`, false, diff --git a/specs/client/codegen/plugins/swift.ts b/specs/client/codegen/plugins/swift.ts index a0d807c1f..99f1913a0 100644 --- a/specs/client/codegen/plugins/swift.ts +++ b/specs/client/codegen/plugins/swift.ts @@ -4,8 +4,13 @@ * Generates Swift types with Codable conformance from GraphQL schema. */ -import { CodegenPlugin, type CodegenPluginConfig } from './base-plugin.js'; -import { generatedFileHeader } from '../core/generated-header.js'; +import { + renderStoreIds, + renderStoreIdentityResolver, + hasStoreIdentity, +} from "../core/store-ids.js"; +import { CodegenPlugin, type CodegenPluginConfig } from "./base-plugin.js"; +import { generatedFileHeader } from "../core/generated-header.js"; import type { IRSchema, IREnum, @@ -17,12 +22,18 @@ import type { IRType, IRField, IROperationField, -} from '../core/types.js'; -import { SWIFT_KEYWORDS, GRAPHQL_TO_SWIFT, requireGraphQLScalarMapping, toLowerCamelCase, capitalize } from '../core/utils.js'; +} from "../core/types.js"; +import { + SWIFT_KEYWORDS, + GRAPHQL_TO_SWIFT, + requireGraphQLScalarMapping, + toLowerCamelCase, + capitalize, +} from "../core/utils.js"; export class SwiftPlugin extends CodegenPlugin { - readonly name = 'swift'; - readonly fileExtension = '.swift'; + readonly name = "swift"; + readonly fileExtension = ".swift"; readonly keywords = SWIFT_KEYWORDS; private schema!: IRSchema; @@ -36,16 +47,18 @@ export class SwiftPlugin extends CodegenPlugin { // ============================================================================ mapScalar(name: string): string { - return requireGraphQLScalarMapping(GRAPHQL_TO_SWIFT, name, 'Swift'); + return requireGraphQLScalarMapping(GRAPHQL_TO_SWIFT, name, "Swift"); } mapType(type: IRType): string { - if (type.kind === 'list') { + if (type.kind === "list") { const elementType = this.mapType(type.elementType!); - const element = type.elementType!.nullable ? `${elementType}?` : elementType; + const element = type.elementType!.nullable + ? `${elementType}?` + : elementType; return `[${element}]`; } - if (type.kind === 'scalar') { + if (type.kind === "scalar") { return this.mapScalar(type.name!); } return type.name!; @@ -69,9 +82,9 @@ export class SwiftPlugin extends CodegenPlugin { generateHeader(): void { for (const line of generatedFileHeader()) this.emit(line); - this.emit(''); - this.emit('import Foundation'); - this.emit(''); + this.emit(""); + this.emit("import Foundation"); + this.emit(""); } // ============================================================================ @@ -83,7 +96,7 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(`public enum ${irEnum.name}: String, Codable, CaseIterable {`); for (const value of irEnum.values) { - this.generateDocComment(value.description, ' '); + this.generateDocComment(value.description, " "); const caseName = this.escapeKeyword(this.enumValueCase(value.name)); this.emit(` case ${caseName} = "${value.rawValue}"`); } @@ -95,24 +108,32 @@ export class SwiftPlugin extends CodegenPlugin { const legacyAliases = new Map( irEnum.values.flatMap((value) => { const targetCase = this.escapeKeyword(this.enumValueCase(value.name)); - return value.legacyAliases.map((alias) => [alias, targetCase] as const); + return value.legacyAliases.map( + (alias) => [alias, targetCase] as const, + ); }), ); - this.emit(''); - this.emit(' /// Custom initializer to handle both kebab-case and camelCase error codes'); - this.emit(' /// This ensures compatibility with react-native-iap and other libraries that may send camelCase'); - this.emit(' public init?(rawValue: String) {'); - this.emit(' // Try direct match first (kebab-case)'); - this.emit(' switch rawValue {'); + this.emit(""); + this.emit( + " /// Custom initializer to handle both kebab-case and camelCase error codes", + ); + this.emit( + " /// This ensures compatibility with react-native-iap and other libraries that may send camelCase", + ); + this.emit(" public init?(rawValue: String) {"); + this.emit(" // Try direct match first (kebab-case)"); + this.emit(" switch rawValue {"); for (const value of irEnum.values) { const caseName = this.escapeKeyword(this.enumValueCase(value.name)); const rawValue = value.rawValue; - const camelCaseName = value.name.charAt(0).toUpperCase() + value.name.slice(1); + const camelCaseName = + value.name.charAt(0).toUpperCase() + value.name.slice(1); // Check if this case is a legacy alias that should map to another case - const aliasTarget = legacyAliases.get(rawValue) ?? legacyAliases.get(camelCaseName); + const aliasTarget = + legacyAliases.get(rawValue) ?? legacyAliases.get(camelCaseName); if (aliasTarget && aliasTarget === caseName) { // This case IS the target - just use normal handling @@ -129,14 +150,14 @@ export class SwiftPlugin extends CodegenPlugin { } } - this.emit(' default:'); - this.emit(' return nil'); - this.emit(' }'); - this.emit(' }'); + this.emit(" default:"); + this.emit(" return nil"); + this.emit(" }"); + this.emit(" }"); } - this.emit('}'); - this.emit(''); + this.emit("}"); + this.emit(""); } // ============================================================================ @@ -148,17 +169,19 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(`public protocol ${irInterface.name}: Codable {`); // Sort fields alphabetically for Swift - const sortedFields = [...irInterface.fields].sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = [...irInterface.fields].sort((a, b) => + a.name.localeCompare(b.name), + ); for (const field of sortedFields) { - this.generateDocComment(field.description, ' '); - this.generateDeprecationAnnotation(field.description, ' '); + this.generateDocComment(field.description, " "); + this.generateDeprecationAnnotation(field.description, " "); const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); this.emit(` var ${propertyName}: ${propertyType} { get }`); } - this.emit('}'); - this.emit(''); + this.emit("}"); + this.emit(""); } // ============================================================================ @@ -167,10 +190,10 @@ export class SwiftPlugin extends CodegenPlugin { generateObject(irObject: IRObject): void { // Handle VoidResult - if (irObject.name === 'VoidResult') { + if (irObject.name === "VoidResult") { this.generateDocComment(irObject.description); - this.emit('public typealias VoidResult = Void'); - this.emit(''); + this.emit("public typealias VoidResult = Void"); + this.emit(""); return; } @@ -181,21 +204,23 @@ export class SwiftPlugin extends CodegenPlugin { } this.generateDocComment(irObject.description); - const conformances = ['Codable', ...irObject.interfaces]; - this.emit(`public struct ${irObject.name}: ${conformances.join(', ')} {`); + const conformances = ["Codable", ...irObject.interfaces]; + this.emit(`public struct ${irObject.name}: ${conformances.join(", ")} {`); // Sort fields alphabetically for Swift - const sortedFields = [...irObject.fields].sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = [...irObject.fields].sort((a, b) => + a.name.localeCompare(b.name), + ); // Properties for (const field of sortedFields) { - this.generateDocComment(field.description, ' '); - this.generateDeprecationAnnotation(field.description, ' '); + this.generateDocComment(field.description, " "); + this.generateDeprecationAnnotation(field.description, " "); const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); const schemaDefault = this.buildDefaultValueExpression(field); - let defaultValue = ''; + let defaultValue = ""; if (schemaDefault) { defaultValue = ` = ${schemaDefault}`; } else if (field.type.nullable) { @@ -203,19 +228,57 @@ export class SwiftPlugin extends CodegenPlugin { // initializer can omit them — existing call sites that construct // objects without every optional keep compiling when new nullable // fields are added. - defaultValue = ' = nil'; + defaultValue = " = nil"; } - this.emit(` public var ${propertyName}: ${propertyType}${defaultValue}`); + this.emit( + ` public var ${propertyName}: ${propertyType}${defaultValue}`, + ); } - // Generate initializer if there are no fields - if (sortedFields.length === 0) { - this.emit(' public init() {}'); - } + this.generatePublicInitializer(sortedFields); - this.emit('}'); - this.emit(''); + if (hasStoreIdentity(sortedFields)) { + this.emit(" private enum CodingKeys: String, CodingKey {"); + this.emit( + ` case ${sortedFields.map((field) => this.escapeKeyword(field.name)).join(", ")}`, + ); + this.emit(" }"); + } + this.emit("}"); + this.emit(""); + if (hasStoreIdentity(sortedFields)) { + this.emit(`extension ${irObject.name} {`); + this.emit(" public init(from decoder: Decoder) throws {"); + this.emit( + " let container = try decoder.container(keyedBy: CodingKeys.self)", + ); + this.emit( + " let store = try container.decode(IapStore.self, forKey: .store)", + ); + for (const field of sortedFields) { + const name = this.escapeKeyword(field.name); + if (field.name === "store") { + this.emit(" self.store = store"); + } else if (field.name === "storeId") { + this.emit( + " guard let storeId = resolveStoreId(store, try container.decodeIfPresent(String.self, forKey: .storeId)) else {", + ); + this.emit( + ' throw DecodingError.dataCorruptedError(forKey: .storeId, in: container, debugDescription: "Invalid store identity")', + ); + this.emit(" }"); + this.emit(" self.storeId = storeId"); + } else { + this.emit( + ` self.${name} = try container.${field.type.nullable ? "decodeIfPresent" : "decode"}(${this.mapType(field.type)}.self, forKey: .${name})`, + ); + } + } + this.emit(" }"); + this.emit("}"); + this.emit(""); + } } private generateResultUnionObject(irObject: IRObject): void { @@ -223,16 +286,18 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(`public enum ${irObject.name} {`); // Sort entries alphabetically - const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => a.fieldName.localeCompare(b.fieldName)); + const sortedEntries = [...irObject.resultUnionEntries!].sort((a, b) => + a.fieldName.localeCompare(b.fieldName), + ); for (const entry of sortedEntries) { - this.generateDocComment(entry.description, ' '); + this.generateDocComment(entry.description, " "); const caseName = this.escapeKeyword(this.enumValueCase(entry.fieldName)); const payloadType = this.getPropertyType(entry.type); this.emit(` case ${caseName}(${payloadType})`); } - this.emit('}'); - this.emit(''); + this.emit("}"); + this.emit(""); } // ============================================================================ @@ -250,59 +315,72 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(`public struct ${irInput.name}: Codable {`); // Sort fields alphabetically for Swift - const sortedFields = [...irInput.fields].sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = [...irInput.fields].sort((a, b) => + a.name.localeCompare(b.name), + ); // Properties for (const field of sortedFields) { - this.generateDocComment(field.description, ' '); - this.generateDeprecationAnnotation(field.description, ' '); + this.generateDocComment(field.description, " "); + this.generateDeprecationAnnotation(field.description, " "); const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); this.emit(` public var ${propertyName}: ${propertyType}`); } - // Generate public initializer + this.generatePublicInitializer(sortedFields); + + this.emit("}"); + this.emit(""); + } + + private generatePublicInitializer(sortedFields: IRField[]): void { if (sortedFields.length > 0) { - this.emit(''); + this.emit(""); const initParams = sortedFields .map((field) => { const propertyType = this.getPropertyType(field.type); - const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); + const propertyName = this.escapeKeyword( + this.fieldNameCase(field.name), + ); const schemaDefault = this.buildDefaultValueExpression(field); - const defaultValue = schemaDefault ? ` = ${schemaDefault}` : field.type.nullable ? ' = nil' : ''; + const defaultValue = schemaDefault + ? ` = ${schemaDefault}` + : field.type.nullable + ? " = nil" + : ""; return ` ${propertyName}: ${propertyType}${defaultValue}`; }) - .join(',\n'); - this.emit(' public init('); + .join(",\n"); + this.emit(" public init("); this.emit(initParams); - this.emit(' ) {'); + this.emit(" ) {"); for (const field of sortedFields) { const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); this.emit(` self.${propertyName} = ${propertyName}`); } - this.emit(' }'); + this.emit(" }"); } else { - this.emit(' public init() {}'); + this.emit(" public init() {}"); } - - this.emit('}'); - this.emit(''); } private generateCustomInput(irInput: IRInput): void { switch (irInput.customTypeKind) { - case 'PurchaseInput': - this.emit('public typealias PurchaseInput = Purchase'); - this.emit(''); + case "PurchaseInput": + this.emit("public typealias PurchaseInput = Purchase"); + this.emit(""); break; - case 'DiscountOfferInputIOS': + case "DiscountOfferInputIOS": this.generateDiscountOfferInputIOS(irInput); break; - case 'RequestPurchaseProps': + case "RequestPurchaseProps": this.generateRequestPurchaseProps(irInput); break; default: - throw new Error(`${irInput.name} is marked as a custom input without a Swift generator strategy.`); + throw new Error( + `${irInput.name} is marked as a custom input without a Swift generator strategy.`, + ); } } @@ -311,20 +389,26 @@ export class SwiftPlugin extends CodegenPlugin { return this.buildDefaultValueForType(field.type, field.defaultValue); } - private buildDefaultValueForType(type: IRType, defaultValue: unknown): string | null { - if (type.kind === 'list') { + 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(', ')}]`; + return `[${items.join(", ")}]`; } - if (type.kind === 'enum' && typeof defaultValue === 'string') { + 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') { + if (type.kind === "scalar") { + if (typeof defaultValue === "string") return `"${defaultValue}"`; + if ( + typeof defaultValue === "number" || + typeof defaultValue === "boolean" + ) { return String(defaultValue); } } @@ -334,144 +418,195 @@ export class SwiftPlugin extends CodegenPlugin { private generateDiscountOfferInputIOS(irInput: IRInput): void { const fields = this.requireCustomInputFields(irInput); this.generateDocComment(irInput.description); - this.emit('public struct DiscountOfferInputIOS: Codable {'); + this.emit("public struct DiscountOfferInputIOS: Codable {"); for (const field of fields) { - this.generateDocComment(field.description, ' '); - this.emit(` public var ${field.name}: ${this.getPropertyType(field.type)}`); - } - this.emit(''); - this.emit(' public init(identifier: String, keyIdentifier: String, nonce: String, signature: String, timestamp: Double) {'); - this.emit(' self.identifier = identifier'); - this.emit(' self.keyIdentifier = keyIdentifier'); - this.emit(' self.nonce = nonce'); - this.emit(' self.signature = signature'); - this.emit(' self.timestamp = timestamp'); - this.emit(' }'); - this.emit(''); - this.emit(' private enum CodingKeys: String, CodingKey {'); - this.emit(' case identifier, keyIdentifier, nonce, signature, timestamp'); - this.emit(' }'); - this.emit(''); - this.emit(' public init(from decoder: Decoder) throws {'); - this.emit(' let container = try decoder.container(keyedBy: CodingKeys.self)'); - this.emit(' identifier = try container.decode(String.self, forKey: .identifier)'); - this.emit(' keyIdentifier = try container.decode(String.self, forKey: .keyIdentifier)'); - this.emit(' nonce = try container.decode(String.self, forKey: .nonce)'); - this.emit(' signature = try container.decode(String.self, forKey: .signature)'); - this.emit(''); - this.emit(' // Flexible timestamp decoding: accept Double or String'); - this.emit(' if let timestampDouble = try? container.decode(Double.self, forKey: .timestamp) {'); - this.emit(' timestamp = timestampDouble'); - this.emit(' } else if let timestampString = try? container.decode(String.self, forKey: .timestamp),'); - this.emit(' let timestampDouble = Double(timestampString) {'); - this.emit(' timestamp = timestampDouble'); - this.emit(' } else {'); - this.emit(' throw DecodingError.dataCorruptedError('); - this.emit(' forKey: .timestamp,'); - this.emit(' in: container,'); - this.emit(' debugDescription: "timestamp must be a number or numeric string"'); - this.emit(' )'); - this.emit(' }'); - this.emit(' }'); - this.emit(''); - this.emit(' public func encode(to encoder: Encoder) throws {'); - this.emit(' var container = encoder.container(keyedBy: CodingKeys.self)'); - this.emit(' try container.encode(identifier, forKey: .identifier)'); - this.emit(' try container.encode(keyIdentifier, forKey: .keyIdentifier)'); - this.emit(' try container.encode(nonce, forKey: .nonce)'); - this.emit(' try container.encode(signature, forKey: .signature)'); - this.emit(' try container.encode(timestamp, forKey: .timestamp)'); - this.emit(' }'); - this.emit('}'); - this.emit(''); + this.generateDocComment(field.description, " "); + this.emit( + ` public var ${field.name}: ${this.getPropertyType(field.type)}`, + ); + } + this.emit(""); + this.emit( + " public init(identifier: String, keyIdentifier: String, nonce: String, signature: String, timestamp: Double) {", + ); + this.emit(" self.identifier = identifier"); + this.emit(" self.keyIdentifier = keyIdentifier"); + this.emit(" self.nonce = nonce"); + this.emit(" self.signature = signature"); + this.emit(" self.timestamp = timestamp"); + this.emit(" }"); + this.emit(""); + this.emit(" private enum CodingKeys: String, CodingKey {"); + this.emit( + " case identifier, keyIdentifier, nonce, signature, timestamp", + ); + this.emit(" }"); + this.emit(""); + this.emit(" public init(from decoder: Decoder) throws {"); + this.emit( + " let container = try decoder.container(keyedBy: CodingKeys.self)", + ); + this.emit( + " identifier = try container.decode(String.self, forKey: .identifier)", + ); + this.emit( + " keyIdentifier = try container.decode(String.self, forKey: .keyIdentifier)", + ); + this.emit( + " nonce = try container.decode(String.self, forKey: .nonce)", + ); + this.emit( + " signature = try container.decode(String.self, forKey: .signature)", + ); + this.emit(""); + this.emit( + " // Flexible timestamp decoding: accept Double or String", + ); + this.emit( + " if let timestampDouble = try? container.decode(Double.self, forKey: .timestamp) {", + ); + this.emit(" timestamp = timestampDouble"); + this.emit( + " } else if let timestampString = try? container.decode(String.self, forKey: .timestamp),", + ); + this.emit( + " let timestampDouble = Double(timestampString) {", + ); + this.emit(" timestamp = timestampDouble"); + this.emit(" } else {"); + this.emit(" throw DecodingError.dataCorruptedError("); + this.emit(" forKey: .timestamp,"); + this.emit(" in: container,"); + this.emit( + ' debugDescription: "timestamp must be a number or numeric string"', + ); + this.emit(" )"); + this.emit(" }"); + this.emit(" }"); + this.emit(""); + this.emit(" public func encode(to encoder: Encoder) throws {"); + this.emit( + " var container = encoder.container(keyedBy: CodingKeys.self)", + ); + this.emit(" try container.encode(identifier, forKey: .identifier)"); + this.emit( + " try container.encode(keyIdentifier, forKey: .keyIdentifier)", + ); + this.emit(" try container.encode(nonce, forKey: .nonce)"); + this.emit(" try container.encode(signature, forKey: .signature)"); + this.emit(" try container.encode(timestamp, forKey: .timestamp)"); + this.emit(" }"); + this.emit("}"); + this.emit(""); } private generateRequestPurchaseProps(irInput: IRInput): void { - const [requestPurchase, requestSubscription, type] = this.requireCustomInputFields(irInput); + const [requestPurchase, requestSubscription, type] = + this.requireCustomInputFields(irInput); this.generateDocComment(irInput.description); - this.emit('public struct RequestPurchaseProps: Codable {'); - this.emit(' public var request: Request'); - this.generateDocComment(type.description, ' '); - this.emit(' public var type: ProductQueryType'); - this.emit(''); - this.emit(' public init(request: Request, type: ProductQueryType? = nil) {'); - this.emit(' switch request {'); - this.emit(' case .purchase:'); - this.emit(' let resolved = type ?? .inApp'); - this.emit(' precondition(resolved == .inApp, "RequestPurchaseProps.type must be .inApp when request is purchase")'); - this.emit(' self.type = resolved'); - this.emit(' case .subscription:'); - this.emit(' let resolved = type ?? .subs'); - this.emit(' precondition(resolved == .subs, "RequestPurchaseProps.type must be .subs when request is subscription")'); - this.emit(' self.type = resolved'); - this.emit(' }'); - this.emit(' self.request = request'); - this.emit(' }'); - this.emit(''); - this.emit(' private enum CodingKeys: String, CodingKey {'); - this.emit(' case requestPurchase'); - this.emit(' case requestSubscription'); - this.emit(' case type'); - this.emit(' }'); - this.emit(''); - this.emit(' public init(from decoder: Decoder) throws {'); - this.emit(' let container = try decoder.container(keyedBy: CodingKeys.self)'); - this.emit(' let decodedType = try container.decodeIfPresent(ProductQueryType.self, forKey: .type)'); - this.emit(' let purchase = try container.decodeIfPresent(RequestPurchasePropsByPlatforms.self, forKey: .requestPurchase)'); + this.emit("public struct RequestPurchaseProps: Codable {"); + this.emit(" public var request: Request"); + this.generateDocComment(type.description, " "); + this.emit(" public var type: ProductQueryType"); + this.emit(""); + this.emit( + " public init(request: Request, type: ProductQueryType? = nil) {", + ); + this.emit(" switch request {"); + this.emit(" case .purchase:"); + this.emit(" let resolved = type ?? .inApp"); + this.emit( + ' precondition(resolved == .inApp, "RequestPurchaseProps.type must be .inApp when request is purchase")', + ); + this.emit(" self.type = resolved"); + this.emit(" case .subscription:"); + this.emit(" let resolved = type ?? .subs"); + this.emit( + ' precondition(resolved == .subs, "RequestPurchaseProps.type must be .subs when request is subscription")', + ); + this.emit(" self.type = resolved"); + this.emit(" }"); + this.emit(" self.request = request"); + this.emit(" }"); + this.emit(""); + this.emit(" private enum CodingKeys: String, CodingKey {"); + this.emit(" case requestPurchase"); + this.emit(" case requestSubscription"); + this.emit(" case type"); + this.emit(" }"); + this.emit(""); + this.emit(" public init(from decoder: Decoder) throws {"); + this.emit( + " let container = try decoder.container(keyedBy: CodingKeys.self)", + ); + this.emit( + " let decodedType = try container.decodeIfPresent(ProductQueryType.self, forKey: .type)", + ); this.emit( - ' let subscription = try container.decodeIfPresent(RequestSubscriptionPropsByPlatforms.self, forKey: .requestSubscription)', + " let purchase = try container.decodeIfPresent(RequestPurchasePropsByPlatforms.self, forKey: .requestPurchase)", + ); + this.emit( + " let subscription = try container.decodeIfPresent(RequestSubscriptionPropsByPlatforms.self, forKey: .requestSubscription)", + ); + this.emit( + " guard (purchase == nil) != (subscription == nil) else {", ); - this.emit(' guard (purchase == nil) != (subscription == nil) else {'); this.emit( ' throw DecodingError.dataCorruptedError(forKey: .requestPurchase, in: container, debugDescription: "RequestPurchaseProps requires exactly one of requestPurchase or requestSubscription.")', ); - this.emit(' }'); - this.emit(' if let purchase {'); - this.emit(' let finalType = decodedType ?? .inApp'); - this.emit(' guard finalType == .inApp else {'); + this.emit(" }"); + this.emit(" if let purchase {"); + this.emit(" let finalType = decodedType ?? .inApp"); + this.emit(" guard finalType == .inApp else {"); this.emit( ' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be IN_APP when requestPurchase is provided")', ); - this.emit(' }'); - this.emit(' self.request = .purchase(purchase)'); - this.emit(' self.type = finalType'); - this.emit(' return'); - this.emit(' }'); - this.emit(' if let subscription {'); - this.emit(' let finalType = decodedType ?? .subs'); - this.emit(' guard finalType == .subs else {'); + this.emit(" }"); + this.emit(" self.request = .purchase(purchase)"); + this.emit(" self.type = finalType"); + this.emit(" return"); + this.emit(" }"); + this.emit(" if let subscription {"); + this.emit(" let finalType = decodedType ?? .subs"); + this.emit(" guard finalType == .subs else {"); this.emit( ' throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "type must be SUBS when requestSubscription is provided")', ); - this.emit(' }'); - this.emit(' self.request = .subscription(subscription)'); - this.emit(' self.type = finalType'); - this.emit(' return'); - this.emit(' }'); + this.emit(" }"); + this.emit(" self.request = .subscription(subscription)"); + this.emit(" self.type = finalType"); + this.emit(" return"); + this.emit(" }"); this.emit( ' throw DecodingError.dataCorruptedError(forKey: .requestPurchase, in: container, debugDescription: "RequestPurchaseProps branch validation failed.")', ); - this.emit(' }'); - this.emit(''); - this.emit(' public func encode(to encoder: Encoder) throws {'); - this.emit(' var container = encoder.container(keyedBy: CodingKeys.self)'); - this.emit(' switch request {'); - this.emit(' case let .purchase(value):'); - this.emit(' try container.encode(value, forKey: .requestPurchase)'); - this.emit(' case let .subscription(value):'); - this.emit(' try container.encode(value, forKey: .requestSubscription)'); - this.emit(' }'); - this.emit(' try container.encode(type, forKey: .type)'); - this.emit(' }'); - this.emit(''); - this.emit(' public enum Request {'); - this.generateDocComment(requestPurchase.description, ' '); - this.emit(' case purchase(RequestPurchasePropsByPlatforms)'); - this.generateDocComment(requestSubscription.description, ' '); - this.emit(' case subscription(RequestSubscriptionPropsByPlatforms)'); - this.emit(' }'); - this.emit('}'); - this.emit(''); + this.emit(" }"); + this.emit(""); + this.emit(" public func encode(to encoder: Encoder) throws {"); + this.emit( + " var container = encoder.container(keyedBy: CodingKeys.self)", + ); + this.emit(" switch request {"); + this.emit(" case let .purchase(value):"); + this.emit( + " try container.encode(value, forKey: .requestPurchase)", + ); + this.emit(" case let .subscription(value):"); + this.emit( + " try container.encode(value, forKey: .requestSubscription)", + ); + this.emit(" }"); + this.emit(" try container.encode(type, forKey: .type)"); + this.emit(" }"); + this.emit(""); + this.emit(" public enum Request {"); + this.generateDocComment(requestPurchase.description, " "); + this.emit(" case purchase(RequestPurchasePropsByPlatforms)"); + this.generateDocComment(requestSubscription.description, " "); + this.emit(" case subscription(RequestSubscriptionPropsByPlatforms)"); + this.emit(" }"); + this.emit("}"); + this.emit(""); } // ============================================================================ @@ -481,8 +616,9 @@ export class SwiftPlugin extends CodegenPlugin { generateUnion(irUnion: IRUnion): void { this.generateDocComment(irUnion.description); - const conformances = ['Codable', ...irUnion.sharedInterfaces]; - const conformanceClause = conformances.length > 0 ? `: ${conformances.join(', ')}` : ''; + const conformances = ["Codable", ...irUnion.sharedInterfaces]; + const conformanceClause = + conformances.length > 0 ? `: ${conformances.join(", ")}` : ""; this.emit(`public enum ${irUnion.name}${conformanceClause} {`); @@ -496,18 +632,22 @@ export class SwiftPlugin extends CodegenPlugin { this.generateUnionInterfaceAccessors(irUnion); } - this.emit('}'); - this.emit(''); + this.emit("}"); + this.emit(""); } private generateUnionInterfaceAccessors(irUnion: IRUnion): void { // Collect fields from shared interfaces const interfaceFields = new Map(); for (const interfaceName of irUnion.sharedInterfaces) { - const irInterface = this.schema.interfaces.find((i) => i.name === interfaceName); + const irInterface = this.schema.interfaces.find( + (i) => i.name === interfaceName, + ); if (!irInterface) continue; // Sort interface fields alphabetically - const sortedFields = [...irInterface.fields].sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = [...irInterface.fields].sort((a, b) => + a.name.localeCompare(b.name), + ); for (const field of sortedFields) { if (!interfaceFields.has(field.name)) { interfaceFields.set(field.name, field); @@ -516,19 +656,21 @@ export class SwiftPlugin extends CodegenPlugin { } if (interfaceFields.size > 0) { - this.emit(''); + this.emit(""); } // Sort the final array alphabetically - const interfaceFieldsArray = [...interfaceFields.entries()].sort((a, b) => a[0].localeCompare(b[0])); + const interfaceFieldsArray = [...interfaceFields.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + ); interfaceFieldsArray.forEach(([fieldName, field], index) => { - this.generateDocComment(field.description, ' '); - this.generateDeprecationAnnotation(field.description, ' '); + this.generateDocComment(field.description, " "); + this.generateDeprecationAnnotation(field.description, " "); const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(fieldName)); this.emit(` public var ${propertyName}: ${propertyType} {`); - this.emit(' switch self {'); + this.emit(" switch self {"); for (const member of irUnion.members) { const caseName = this.escapeKeyword(this.enumValueCase(member.name)); @@ -536,11 +678,11 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(` return value.${propertyName}`); } - this.emit(' }'); - this.emit(' }'); + this.emit(" }"); + this.emit(" }"); // Add blank line between properties (except after the last one) if (index < interfaceFieldsArray.length - 1) { - this.emit(''); + this.emit(""); } }); } @@ -566,10 +708,13 @@ export class SwiftPlugin extends CodegenPlugin { // Header this.generateHeader(); + this.emit(renderStoreIds("swift")); + if (schema.enums.some((item) => item.name === "IapStore")) + this.emit(renderStoreIdentityResolver("swift")); // Enums if (schema.enums.length > 0) { - this.addSectionComment('Enums'); + this.addSectionComment("Enums"); for (const irEnum of schema.enums) { this.generateEnum(irEnum); } @@ -577,7 +722,7 @@ export class SwiftPlugin extends CodegenPlugin { // Interfaces if (schema.interfaces.length > 0) { - this.addSectionComment('Interfaces'); + this.addSectionComment("Interfaces"); for (const irInterface of schema.interfaces) { this.generateInterface(irInterface); } @@ -585,7 +730,7 @@ export class SwiftPlugin extends CodegenPlugin { // Objects if (schema.objects.length > 0) { - this.addSectionComment('Objects'); + this.addSectionComment("Objects"); for (const irObject of schema.objects) { this.generateObject(irObject); } @@ -593,7 +738,7 @@ export class SwiftPlugin extends CodegenPlugin { // Inputs if (schema.inputs.length > 0) { - this.addSectionComment('Input Objects'); + this.addSectionComment("Input Objects"); for (const irInput of schema.inputs) { this.generateInput(irInput); } @@ -601,7 +746,7 @@ export class SwiftPlugin extends CodegenPlugin { // Unions if (schema.unions.length > 0) { - this.addSectionComment('Unions'); + this.addSectionComment("Unions"); for (const irUnion of schema.unions) { this.generateUnion(irUnion); } @@ -609,7 +754,7 @@ export class SwiftPlugin extends CodegenPlugin { // Operations - Protocols first if (schema.operations.length > 0) { - this.addSectionComment('Root Operations'); + this.addSectionComment("Root Operations"); for (const irOperation of schema.operations) { this.generateOperationProtocol(irOperation); } @@ -617,35 +762,44 @@ export class SwiftPlugin extends CodegenPlugin { // Operations - Helpers second (matching original order) if (schema.operations.length > 0) { - this.addSectionComment('Root Operation Helpers'); + this.addSectionComment("Root Operation Helpers"); for (const irOperation of schema.operations) { this.generateOperationHelpers(irOperation); } } - const output = this.lines.join('\n'); + const output = this.lines.join("\n"); return this.postProcess(output); } private generateOperationProtocol(irOperation: IROperation): void { const protocolName = `${irOperation.name}Resolver`; - this.generateDocComment(irOperation.description ?? `GraphQL root ${irOperation.name.toLowerCase()} operations.`); + this.generateDocComment( + irOperation.description ?? + `GraphQL root ${irOperation.name.toLowerCase()} operations.`, + ); this.emit(`public protocol ${protocolName} {`); // Sort fields alphabetically and filter _placeholder - const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields + .filter((f) => f.name !== "_placeholder") + .sort((a, b) => a.name.localeCompare(b.name)); for (const field of sortedFields) { - this.generateDocComment(this.operationFieldDescription(field), ' '); + this.generateDocComment(this.operationFieldDescription(field), " "); const returnType = this.getOperationReturnType(field); if (field.args.length === 0) { - this.emit(` func ${this.escapeKeyword(field.name)}() async throws -> ${returnType}`); + this.emit( + ` func ${this.escapeKeyword(field.name)}() async throws -> ${returnType}`, + ); } else if (field.args.length === 1) { const arg = field.args[0]; const argType = this.getPropertyType(arg.type); const argName = this.escapeKeyword(arg.name); - this.emit(` func ${this.escapeKeyword(field.name)}(_ ${argName}: ${argType}) async throws -> ${returnType}`); + this.emit( + ` func ${this.escapeKeyword(field.name)}(_ ${argName}: ${argType}) async throws -> ${returnType}`, + ); } else { const params = field.args .map((arg) => { @@ -653,23 +807,27 @@ export class SwiftPlugin extends CodegenPlugin { const argName = this.escapeKeyword(arg.name); return `${argName}: ${argType}`; }) - .join(', '); - this.emit(` func ${this.escapeKeyword(field.name)}(${params}) async throws -> ${returnType}`); + .join(", "); + this.emit( + ` func ${this.escapeKeyword(field.name)}(${params}) async throws -> ${returnType}`, + ); } } - this.emit('}'); - this.emit(''); + this.emit("}"); + this.emit(""); } private generateOperationHelpers(irOperation: IROperation): void { // Sort fields alphabetically and filter _placeholder - const sortedFields = irOperation.fields.filter((f) => f.name !== '_placeholder').sort((a, b) => a.name.localeCompare(b.name)); + const sortedFields = irOperation.fields + .filter((f) => f.name !== "_placeholder") + .sort((a, b) => a.name.localeCompare(b.name)); if (sortedFields.length === 0) return; this.emit(`// MARK: - ${irOperation.name} Helpers`); - this.emit(''); + this.emit(""); // Generate typealiases for handlers for (const field of sortedFields) { @@ -677,43 +835,51 @@ export class SwiftPlugin extends CodegenPlugin { const returnType = this.getOperationReturnType(field); if (field.args.length === 0) { - this.emit(`public typealias ${aliasName} = () async throws -> ${returnType}`); + this.emit( + `public typealias ${aliasName} = () async throws -> ${returnType}`, + ); } else { const params = field.args .map((arg) => { const argType = this.getPropertyType(arg.type); return `_ ${this.escapeKeyword(arg.name)}: ${argType}`; }) - .join(', '); - this.emit(`public typealias ${aliasName} = (${params}) async throws -> ${returnType}`); + .join(", "); + this.emit( + `public typealias ${aliasName} = (${params}) async throws -> ${returnType}`, + ); } } // Generate handlers struct const structName = `${irOperation.name}Handlers`; - this.emit(''); + this.emit(""); this.emit(`public struct ${structName} {`); for (const field of sortedFields) { const aliasName = `${irOperation.name}${capitalize(field.name)}Handler`; - this.emit(` public var ${this.escapeKeyword(field.name)}: ${aliasName}?`); + this.emit( + ` public var ${this.escapeKeyword(field.name)}: ${aliasName}?`, + ); } - this.emit(''); + this.emit(""); const initParams = sortedFields .map((field) => { const aliasName = `${irOperation.name}${capitalize(field.name)}Handler`; return `${this.escapeKeyword(field.name)}: ${aliasName}? = nil`; }) - .join(',\n '); - this.emit(` public init(${sortedFields.length > 0 ? `\n ${initParams}\n ` : ''}) {`); + .join(",\n "); + this.emit( + ` public init(${sortedFields.length > 0 ? `\n ${initParams}\n ` : ""}) {`, + ); for (const field of sortedFields) { const propertyName = this.escapeKeyword(field.name); this.emit(` self.${propertyName} = ${propertyName}`); } - this.emit(' }'); - this.emit('}'); - this.emit(''); + this.emit(" }"); + this.emit("}"); + this.emit(""); } // ============================================================================ @@ -729,24 +895,36 @@ export class SwiftPlugin extends CodegenPlugin { const resolved = field.resolvedReturnType; // Handle Void - if (resolved.kind === 'scalar' && resolved.name === 'Void') { - return resolved.nullable ? 'Void?' : 'Void'; + if (resolved.kind === "scalar" && resolved.name === "Void") { + return resolved.nullable ? "Void?" : "Void"; } return this.getPropertyType(resolved); } - protected generateDocComment(description: string | undefined, indent: string = ''): void { + protected generateDocComment( + description: string | undefined, + indent: string = "", + ): void { if (!description) return; for (const line of description.split(/\r?\n/)) { this.emit(line ? `${indent}/// ${line}` : `${indent}///`); } } - private generateDeprecationAnnotation(description: string | undefined, indent: string = ''): void { - const reason = description?.match(/(?:^|\n)@deprecated\s+([^\n]+)/)?.[1]?.trim(); + private generateDeprecationAnnotation( + description: string | undefined, + indent: string = "", + ): void { + const reason = description + ?.match(/(?:^|\n)@deprecated\s+([^\n]+)/)?.[1] + ?.trim(); if (!reason) return; - const escapedReason = reason.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); - this.emit(`${indent}@available(*, deprecated, message: "${escapedReason}")`); + const escapedReason = reason + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"'); + this.emit( + `${indent}@available(*, deprecated, message: "${escapedReason}")`, + ); } } diff --git a/specs/client/package.json b/specs/client/package.json index b3b8a73f3..204f1e2da 100644 --- a/specs/client/package.json +++ b/specs/client/package.json @@ -1,6 +1,6 @@ { "name": "@hyodotdev/openiap-client-protocol", - "version": "0.1.1", + "version": "0.2.0", "publishConfig": { "access": "public" }, diff --git a/specs/client/src/api.graphql b/specs/client/src/api.graphql index 9e85daeec..4c7e068dd 100644 --- a/specs/client/src/api.graphql +++ b/specs/client/src/api.graphql @@ -52,6 +52,7 @@ extend type Mutation { endConnection: Boolean! """ Initiate a purchase or subscription flow; rely on events for final state. + Providers emit one canonical purchase-error event before returning or throwing a request failure. See: https://openiap.dev/docs/apis/request-purchase """ # Future diff --git a/specs/client/src/capability-matrix.mjs b/specs/client/src/capability-matrix.mjs index 0cf8fd1a2..bd912163b 100644 --- a/specs/client/src/capability-matrix.mjs +++ b/specs/client/src/capability-matrix.mjs @@ -15,9 +15,9 @@ /** @typedef {'required' | 'optional' | 'unsupported'} CapabilityLevel */ export const CAPABILITY_LEVELS = Object.freeze([ - 'required', - 'optional', - 'unsupported', + "required", + "optional", + "unsupported", ]); /** @@ -26,10 +26,10 @@ export const CAPABILITY_LEVELS = Object.freeze([ * than an implementation. */ export const CAPABILITY_STORES = Object.freeze([ - 'Apple', - 'Google', - 'Amazon', - 'Horizon', + "Apple", + "Google", + "Amazon", + "Horizon", ]); /** @@ -40,127 +40,152 @@ export const CAPABILITY_STORES = Object.freeze([ */ export const CAPABILITY_MATRIX = Object.freeze({ fetchProducts: { - description: 'Fetch products and subscriptions from the store.', - stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + description: "Fetch products and subscriptions from the store.", + stores: { + Apple: "required", + Google: "required", + Amazon: "required", + Horizon: "required", + }, }, requestPurchase: { - description: 'Initiate a purchase or subscription flow.', - stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + description: "Initiate a purchase or subscription flow.", + stores: { + Apple: "required", + Google: "required", + Amazon: "required", + Horizon: "required", + }, }, finishTransaction: { - description: 'Complete a transaction after verification.', - stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + description: "Complete a transaction after verification.", + stores: { + Apple: "required", + Google: "required", + Amazon: "required", + Horizon: "required", + }, }, getAvailablePurchases: { - description: 'List restorable/active purchases for the current user.', - stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + description: "List restorable/active purchases for the current user.", + stores: { + Apple: "required", + Google: "required", + Amazon: "required", + Horizon: "required", + }, }, getActiveSubscriptions: { description: - 'Report active subscriptions. A purchase that is not in the Purchased state is never an active entitlement.', - stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + "Report active subscriptions. A purchase that is not in the Purchased state is never an active entitlement.", + stores: { + Apple: "required", + Google: "required", + Amazon: "required", + Horizon: "required", + }, }, pendingPurchases: { description: - 'Represent a deferred/unpaid purchase, and never treat one as an active entitlement.', + "Represent a deferred/unpaid purchase, and never treat one as an active entitlement.", stores: { - Apple: 'required', - Google: 'required', - Amazon: 'required', - Horizon: 'required', + Apple: "required", + Google: "required", + Amazon: "required", + Horizon: "required", }, notes: { Apple: - 'Shape differs: StoreKit surfaces Product.PurchaseResult.pending as an ErrorCode.DeferredPayment error (packages/apple/Sources/OpenIapModule.swift), whereas Android delivers a Purchase carrying PurchaseState.Pending.', + "Shape differs: StoreKit surfaces Product.PurchaseResult.pending as an ErrorCode.DeferredPayment error (packages/apple/Sources/OpenIapStoreKitModule.swift), whereas Android delivers a Purchase carrying PurchaseState.Pending.", }, evidence: { Amazon: - 'packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt — pending purchases are enabled and PurchaseResponse.RequestStatus.PENDING maps to DeferredPurchase.', + "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt — pending purchases are enabled and PurchaseResponse.RequestStatus.PENDING maps to DeferredPurchase.", }, }, subscriptionBillingIssue: { description: - 'Emit IapEvent.SubscriptionBillingIssue when a subscription enters a billing-retry/suspended state.', + "Emit IapEvent.SubscriptionBillingIssue when a subscription enters a billing-retry/suspended state.", stores: { - Apple: 'required', - Google: 'required', - Amazon: 'unsupported', - Horizon: 'unsupported', + Apple: "required", + Google: "required", + Amazon: "unsupported", + Horizon: "unsupported", }, evidence: { Amazon: - 'specs/client/src/type.graphql (IapEvent.SubscriptionBillingIssue) — Amazon Appstore exposes no suspension signal.', + "specs/client/src/type.graphql (IapEvent.SubscriptionBillingIssue) — Amazon Appstore exposes no suspension signal.", Horizon: - 'Horizon Billing Compatibility SDK implements Play Billing 7.0, which predates isSuspended.', + "Horizon Billing Compatibility SDK implements Play Billing 7.0, which predates isSuspended.", }, }, offerCodeRedemption: { - description: 'Expose an offer-code redemption entry point.', + description: "Expose an offer-code redemption entry point.", stores: { - Apple: 'required', - Google: 'required', - Amazon: 'unsupported', - Horizon: 'unsupported', + Apple: "required", + Google: "required", + Amazon: "unsupported", + Horizon: "unsupported", }, evidence: { Amazon: - 'packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/OpenRedeemOfferCodeAmazonNoOpTest.kt', + "packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/OpenRedeemOfferCodeAmazonNoOpTest.kt", Horizon: - 'packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/OpenRedeemOfferCodeHorizonNoOpTest.kt', + "packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/OpenRedeemOfferCodeHorizonNoOpTest.kt", }, }, alreadyOwnedError: { description: - 'Surface ErrorCode.AlreadyOwned when a purchase is attempted for an item the user already owns.', + "Surface ErrorCode.AlreadyOwned when a purchase is attempted for an item the user already owns.", stores: { - Apple: 'unsupported', - Google: 'required', - Amazon: 'required', - Horizon: 'required', + Apple: "unsupported", + Google: "required", + Amazon: "required", + Horizon: "required", }, evidence: { Apple: - 'StoreKit has no already-owned failure: re-purchasing an owned non-consumable succeeds and returns the existing transaction. See packages/apple/Sources/Models/OpenIapError.swift (errorCode(for:)).', + "StoreKit has no already-owned failure: re-purchasing an owned non-consumable succeeds and returns the existing transaction. See packages/apple/Sources/Models/OpenIapError.swift (errorCode(for:)).", }, }, billingServiceLifecycleErrors: { description: - 'Surface ErrorCode.BillingUnavailable / ServiceDisconnected / ServiceTimeout for billing-service lifecycle failures.', + "Surface ErrorCode.BillingUnavailable / ServiceDisconnected / ServiceTimeout for billing-service lifecycle failures.", stores: { - Apple: 'unsupported', - Google: 'required', - Amazon: 'required', - Horizon: 'required', + Apple: "unsupported", + Google: "required", + Amazon: "required", + Horizon: "required", }, evidence: { Apple: - 'StoreKit exposes no long-lived billing-service connection, so these conditions do not arise. See packages/apple/Sources/Models/OpenIapError.swift (errorCode(for:)).', + "StoreKit exposes no long-lived billing-service connection, so these conditions do not arise. See packages/apple/Sources/Models/OpenIapError.swift (errorCode(for:)).", }, }, storeWebhookLifecycle: { description: - 'Deliver server-side lifecycle notifications that IAPKit normalizes into subscription state transitions.', + "Deliver server-side lifecycle notifications that IAPKit normalizes into subscription state transitions.", stores: { - Apple: 'required', - Google: 'required', - Amazon: 'optional', - Horizon: 'optional', + Apple: "required", + Google: "required", + Amazon: "optional", + Horizon: "optional", }, evidence: { Amazon: - 'packages/kit/convex/purchases/amazon.ts (reconcileAmazonPurchases) — reconciliation by polling rather than push notifications.', + "packages/kit/convex/purchases/amazon.ts (reconcileAmazonPurchases) — reconciliation by polling rather than push notifications.", Horizon: - 'packages/kit/convex/purchases/horizon.ts — verification only. Meta publishes subscription and order webhook fields; IAPKit subscribes to none of them.', + "packages/kit/convex/purchases/horizon.ts — verification only. Meta publishes subscription and order webhook fields; IAPKit subscribes to none of them.", }, }, }); @@ -174,20 +199,21 @@ export function capabilityLevel(behavior, store) { const entry = CAPABILITY_MATRIX[behavior]; if (!entry) throw new Error(`Unknown capability behavior: ${behavior}`); const level = entry.stores[store]; - if (!level) throw new Error(`Capability ${behavior} has no entry for store ${store}`); + if (!level) + throw new Error(`Capability ${behavior} has no entry for store ${store}`); return level; } /** Behaviors a given store must implement. */ export function requiredBehaviors(store) { return Object.keys(CAPABILITY_MATRIX).filter( - (behavior) => CAPABILITY_MATRIX[behavior].stores[store] === 'required', + (behavior) => CAPABILITY_MATRIX[behavior].stores[store] === "required", ); } /** Behaviors a given store must NOT implement. */ export function unsupportedBehaviors(store) { return Object.keys(CAPABILITY_MATRIX).filter( - (behavior) => CAPABILITY_MATRIX[behavior].stores[store] === 'unsupported', + (behavior) => CAPABILITY_MATRIX[behavior].stores[store] === "unsupported", ); } diff --git a/specs/client/src/generated-compatibility.test.ts b/specs/client/src/generated-compatibility.test.ts index 4ebac3856..fe06db69a 100644 --- a/specs/client/src/generated-compatibility.test.ts +++ b/specs/client/src/generated-compatibility.test.ts @@ -474,7 +474,7 @@ export interface WrongOwner { kotlin.indexOf('public data class PurchaseIOS('), ); const iapkitResult = kotlin.slice( - kotlin.indexOf('public data class RequestVerifyPurchaseWithIapkitResult('), + kotlin.indexOf('public class RequestVerifyPurchaseWithIapkitResult'), kotlin.indexOf('public data class SubscriptionCommitmentInfoIOS('), ); const iapkitAmazonProps = kotlin.slice( @@ -499,7 +499,7 @@ export interface WrongOwner { expect(userChoice).toContain('private set'); expect(purchaseError).toContain('private set'); expect(iapkitResultPrimary).toContain( - 'public data class RequestVerifyPurchaseWithIapkitResult( val isValid: Boolean, val state: IapkitPurchaseState, val store: IapStore ) {', + 'public class RequestVerifyPurchaseWithIapkitResult private constructor( val isValid: Boolean, val state: IapkitPurchaseState, val store: IapStore, explicitStoreId: String?, ) {', ); expect(iapkitResult).toContain('var clientPayload: IapkitProductClientPayload? = null'); expect(iapkitResult).toContain('var productId: String? = null'); @@ -538,6 +538,36 @@ export interface WrongOwner { expect(iapkitPropsPrimary).not.toContain('horizon'); }); + it('keeps store identity consistent in generated Kotlin', () => { + const kotlin = generated('Types.kt'); + const iapkitResult = kotlin.slice( + kotlin.indexOf('public class RequestVerifyPurchaseWithIapkitResult'), + kotlin.indexOf('public data class SubscriptionCommitmentInfoIOS('), + ); + const descriptor = kotlin.slice( + kotlin.indexOf('public data class StoreProviderDescriptor('), + kotlin.indexOf('public data class SubscriptionCommitmentInfoIOS('), + ); + + expect(iapkitResult).not.toContain('IapStore.Unknown -> "unknown"'); + expect(iapkitResult).toContain( + 'RequestVerifyPurchaseWithIapkitResult with store Unknown requires an explicit valid storeId', + ); + expect(iapkitResult).toContain('explicitStoreId?.let { resolveStoreId(store, it) }'); + expect(iapkitResult).toContain('explicitStoreId = null,'); + expect(iapkitResult).toContain('explicitStoreId = storeId,'); + expect(iapkitResult).toContain('storeId: String? = null,'); + expect(iapkitResult).toContain('val resolvedStoreId = storeId?.let { resolveStoreId(store, it) }'); + expect(iapkitResult).toContain('if (store == this.store) this.storeId else when (store)'); + expect(iapkitResult).toContain('storeId = resolvedStoreId,'); + expect(iapkitResult).not.toContain('storeId = this.storeId,'); + expect(descriptor).toContain('Missing or blank storeId for StoreProviderDescriptor'); + expect(descriptor).toContain('Missing or blank coreVersion for StoreProviderDescriptor'); + expect(descriptor).toContain('Missing or blank clientProtocolVersion for StoreProviderDescriptor'); + expect(descriptor).toContain('Missing capabilities for StoreProviderDescriptor'); + expect(descriptor).not.toContain('as? String ?: ""'); + }); + it('preserves schema prose in custom purchase and discount generators', () => { const swift = generated('Types.swift'); const kotlin = generated('Types.kt'); diff --git a/specs/client/src/generated/Types.cs b/specs/client/src/generated/Types.cs index c3f8b7d85..4a5c3c3f3 100644 --- a/specs/client/src/generated/Types.cs +++ b/specs/client/src/generated/Types.cs @@ -125,6 +125,33 @@ public override void Write(Utf8JsonWriter writer, IReadOnlyList value, J } } +public static class StoreIds +{ + public const string Apple = "apple"; + public const string Play = "play"; + public const string Horizon = "horizon"; + public const string Amazon = "amazon"; +} + +internal static class StoreIdentity +{ + internal static string Resolve(IapStore store, string? value) + { + var official = store switch + { + IapStore.Apple => "apple", + IapStore.Google => "play", + IapStore.Horizon => "horizon", + IapStore.Amazon => "amazon", + _ => null, + }; + var id = value ?? official; + if (id is null || (official is not null ? id != official : !System.Text.RegularExpressions.Regex.IsMatch(id, @"\A[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*\z") || id is "auto" or "none" or "unknown" or "apple" or "play" or "google" or "horizon" or "amazon")) + throw new JsonException("Invalid store identity."); + return id; + } +} + // ============================================================================ // Enums // ============================================================================ @@ -1365,6 +1392,7 @@ public static class IapPlatformExtensions public static IapPlatform FromJson(string value) => IapPlatformJsonConverter.FromRawString(value); } +/// Frozen legacy store discriminator. Use storeId for extensible store identity. [JsonConverter(typeof(IapStoreJsonConverter))] public enum IapStore { @@ -2478,6 +2506,7 @@ public interface PurchaseCommon string? CurrentPlanId { get; } string Id { get; } IReadOnlyList? Ids { get; } + /// Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. bool IsAutoRenewing { get; } string ProductId { get; } PurchaseState PurchaseState { get; } @@ -2486,6 +2515,8 @@ public interface PurchaseCommon int Quantity { get; } /// Store where purchase was made IapStore Store { get; } + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + string StoreId { get; } /// Unix timestamp in milliseconds since January 1, 1970 UTC. double TransactionDate { get; } } @@ -2556,6 +2587,7 @@ public abstract record Purchase : PurchaseCommon public abstract string? CurrentPlanId { get; init; } public abstract string Id { get; init; } public abstract IReadOnlyList? Ids { get; init; } + /// Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. public abstract bool IsAutoRenewing { get; init; } public abstract string ProductId { get; init; } public abstract PurchaseState PurchaseState { get; init; } @@ -2564,6 +2596,8 @@ public abstract record Purchase : PurchaseCommon public abstract int Quantity { get; init; } /// Store where purchase was made public abstract IapStore Store { get; init; } + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + public abstract string StoreId { get; init; } /// Unix timestamp in milliseconds since January 1, 1970 UTC. public abstract double TransactionDate { get; init; } } @@ -2584,6 +2618,7 @@ public abstract record VerifyPurchaseResult : VerifyPurchaseResultCommon public sealed record ActiveSubscription { + /// Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. [JsonPropertyName("autoRenewingAndroid")] public bool? AutoRenewingAndroid { get; init; } [JsonPropertyName("basePlanIdAndroid")] @@ -3463,8 +3498,9 @@ public sealed record ProductSubscriptionIOS : ProductSubscription public required ProductTypeIOS TypeIOS { get; init; } } -public sealed record PurchaseAndroid : Purchase +public sealed record PurchaseAndroid : Purchase, IJsonOnDeserialized { + /// Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. [JsonPropertyName("autoRenewingAndroid")] public bool? AutoRenewingAndroid { get; init; } [JsonPropertyName("currentPlanId")] @@ -3479,6 +3515,7 @@ public sealed record PurchaseAndroid : Purchase public override IReadOnlyList? Ids { get; init; } [JsonPropertyName("isAcknowledgedAndroid")] public bool? IsAcknowledgedAndroid { get; init; } + /// Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. [JsonPropertyName("isAutoRenewing")] public override required bool IsAutoRenewing { get; init; } /// @@ -3517,6 +3554,10 @@ public sealed record PurchaseAndroid : Purchase /// Store where purchase was made [JsonPropertyName("store")] public override required IapStore Store { get; init; } + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + private string? _storeId; + [JsonPropertyName("storeId")] + public override string StoreId { get => StoreIdentity.Resolve(Store, _storeId); init => _storeId = value; } /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] public override required double TransactionDate { get; init; } @@ -3535,6 +3576,62 @@ public sealed record PurchaseAndroid : Purchase /// [JsonPropertyName("userMarketplaceAmazon")] public string? UserMarketplaceAmazon { get; init; } + void IJsonOnDeserialized.OnDeserialized() => _storeId = StoreIdentity.Resolve(Store, _storeId); + public bool Equals(PurchaseAndroid? other) => other is not null + && EqualityComparer.Default.Equals(AutoRenewingAndroid, other.AutoRenewingAndroid) + && EqualityComparer.Default.Equals(CurrentPlanId, other.CurrentPlanId) + && EqualityComparer.Default.Equals(DataAndroid, other.DataAndroid) + && EqualityComparer.Default.Equals(DeveloperPayloadAndroid, other.DeveloperPayloadAndroid) + && EqualityComparer.Default.Equals(Id, other.Id) + && EqualityComparer?>.Default.Equals(Ids, other.Ids) + && EqualityComparer.Default.Equals(IsAcknowledgedAndroid, other.IsAcknowledgedAndroid) + && EqualityComparer.Default.Equals(IsAutoRenewing, other.IsAutoRenewing) + && EqualityComparer.Default.Equals(IsSuspendedAndroid, other.IsSuspendedAndroid) + && EqualityComparer.Default.Equals(ObfuscatedAccountIdAndroid, other.ObfuscatedAccountIdAndroid) + && EqualityComparer.Default.Equals(ObfuscatedProfileIdAndroid, other.ObfuscatedProfileIdAndroid) + && EqualityComparer.Default.Equals(PackageNameAndroid, other.PackageNameAndroid) + && EqualityComparer.Default.Equals(PendingPurchaseUpdateAndroid, other.PendingPurchaseUpdateAndroid) + && EqualityComparer.Default.Equals(ProductId, other.ProductId) + && EqualityComparer.Default.Equals(PurchaseState, other.PurchaseState) + && EqualityComparer.Default.Equals(PurchaseToken, other.PurchaseToken) + && EqualityComparer.Default.Equals(Quantity, other.Quantity) + && EqualityComparer.Default.Equals(SignatureAndroid, other.SignatureAndroid) + && EqualityComparer.Default.Equals(Store, other.Store) + && EqualityComparer.Default.Equals(StoreId, other.StoreId) + && EqualityComparer.Default.Equals(TransactionDate, other.TransactionDate) + && EqualityComparer.Default.Equals(TransactionId, other.TransactionId) + && EqualityComparer.Default.Equals(UserIdAmazon, other.UserIdAmazon) + && EqualityComparer.Default.Equals(UserMarketplaceAmazon, other.UserMarketplaceAmazon); + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(typeof(PurchaseAndroid)); + hash.Add(AutoRenewingAndroid); + hash.Add(CurrentPlanId); + hash.Add(DataAndroid); + hash.Add(DeveloperPayloadAndroid); + hash.Add(Id); + hash.Add(Ids); + hash.Add(IsAcknowledgedAndroid); + hash.Add(IsAutoRenewing); + hash.Add(IsSuspendedAndroid); + hash.Add(ObfuscatedAccountIdAndroid); + hash.Add(ObfuscatedProfileIdAndroid); + hash.Add(PackageNameAndroid); + hash.Add(PendingPurchaseUpdateAndroid); + hash.Add(ProductId); + hash.Add(PurchaseState); + hash.Add(PurchaseToken); + hash.Add(Quantity); + hash.Add(SignatureAndroid); + hash.Add(Store); + hash.Add(StoreId); + hash.Add(TransactionDate); + hash.Add(TransactionId); + hash.Add(UserIdAmazon); + hash.Add(UserMarketplaceAmazon); + return hash.ToHashCode(); + } } public sealed record PurchaseError @@ -3559,7 +3656,7 @@ public sealed record PurchaseError public SubResponseCodeAndroid? SubResponseCodeAndroid { get; init; } } -public sealed record PurchaseIOS : Purchase +public sealed record PurchaseIOS : Purchase, IJsonOnDeserialized { /// /// Advanced Commerce API metadata (iOS 18.4+). @@ -3609,6 +3706,7 @@ public sealed record PurchaseIOS : Purchase public override required string Id { get; init; } [JsonPropertyName("ids")] public override IReadOnlyList? Ids { get; init; } + /// Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. [JsonPropertyName("isAutoRenewing")] public override required bool IsAutoRenewing { get; init; } [JsonPropertyName("isUpgradedIOS")] @@ -3660,6 +3758,10 @@ public sealed record PurchaseIOS : Purchase public override required IapStore Store { get; init; } [JsonPropertyName("storefrontCountryCodeIOS")] public string? StorefrontCountryCodeIOS { get; init; } + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + private string? _storeId; + [JsonPropertyName("storeId")] + public override string StoreId { get => StoreIdentity.Resolve(Store, _storeId); init => _storeId = value; } [JsonPropertyName("subscriptionGroupIdIOS")] public string? SubscriptionGroupIdIOS { get; init; } /// Unix timestamp in milliseconds since January 1, 1970 UTC. @@ -3671,6 +3773,100 @@ public sealed record PurchaseIOS : Purchase public string? TransactionReasonIOS { get; init; } [JsonPropertyName("webOrderLineItemIdIOS")] public string? WebOrderLineItemIdIOS { get; init; } + void IJsonOnDeserialized.OnDeserialized() => _storeId = StoreIdentity.Resolve(Store, _storeId); + public bool Equals(PurchaseIOS? other) => other is not null + && EqualityComparer.Default.Equals(AdvancedCommerceInfoIOS, other.AdvancedCommerceInfoIOS) + && EqualityComparer.Default.Equals(AppAccountToken, other.AppAccountToken) + && EqualityComparer.Default.Equals(AppBundleIdIOS, other.AppBundleIdIOS) + && EqualityComparer.Default.Equals(BillingPlanTypeIOS, other.BillingPlanTypeIOS) + && EqualityComparer.Default.Equals(BundleOriginalTransactionIdIOS, other.BundleOriginalTransactionIdIOS) + && EqualityComparer.Default.Equals(BundleProductIdIOS, other.BundleProductIdIOS) + && EqualityComparer.Default.Equals(BundleSubscriptionGroupIdIOS, other.BundleSubscriptionGroupIdIOS) + && EqualityComparer.Default.Equals(BundleTransactionIdIOS, other.BundleTransactionIdIOS) + && EqualityComparer.Default.Equals(CommitmentInfoIOS, other.CommitmentInfoIOS) + && EqualityComparer.Default.Equals(CountryCodeIOS, other.CountryCodeIOS) + && EqualityComparer.Default.Equals(CurrencyCodeIOS, other.CurrencyCodeIOS) + && EqualityComparer.Default.Equals(CurrencySymbolIOS, other.CurrencySymbolIOS) + && EqualityComparer.Default.Equals(CurrentPlanId, other.CurrentPlanId) + && EqualityComparer.Default.Equals(EnvironmentIOS, other.EnvironmentIOS) + && EqualityComparer.Default.Equals(ExpirationDateIOS, other.ExpirationDateIOS) + && EqualityComparer.Default.Equals(Id, other.Id) + && EqualityComparer?>.Default.Equals(Ids, other.Ids) + && EqualityComparer.Default.Equals(IsAutoRenewing, other.IsAutoRenewing) + && EqualityComparer.Default.Equals(IsUpgradedIOS, other.IsUpgradedIOS) + && EqualityComparer.Default.Equals(OfferIOS, other.OfferIOS) + && EqualityComparer.Default.Equals(OriginalTransactionDateIOS, other.OriginalTransactionDateIOS) + && EqualityComparer.Default.Equals(OriginalTransactionIdentifierIOS, other.OriginalTransactionIdentifierIOS) + && EqualityComparer.Default.Equals(OwnershipTypeIOS, other.OwnershipTypeIOS) + && EqualityComparer.Default.Equals(PreviousOriginalTransactionIdIOS, other.PreviousOriginalTransactionIdIOS) + && EqualityComparer.Default.Equals(ProductId, other.ProductId) + && EqualityComparer.Default.Equals(PurchaseState, other.PurchaseState) + && EqualityComparer.Default.Equals(PurchaseToken, other.PurchaseToken) + && EqualityComparer.Default.Equals(Quantity, other.Quantity) + && EqualityComparer.Default.Equals(QuantityIOS, other.QuantityIOS) + && EqualityComparer.Default.Equals(ReasonIOS, other.ReasonIOS) + && EqualityComparer.Default.Equals(ReasonStringRepresentationIOS, other.ReasonStringRepresentationIOS) + && EqualityComparer.Default.Equals(RenewalInfoIOS, other.RenewalInfoIOS) + && EqualityComparer.Default.Equals(RevocationDateIOS, other.RevocationDateIOS) + && EqualityComparer.Default.Equals(RevocationReasonIOS, other.RevocationReasonIOS) + && EqualityComparer.Default.Equals(RevocationTypeIOS, other.RevocationTypeIOS) + && EqualityComparer.Default.Equals(Store, other.Store) + && EqualityComparer.Default.Equals(StorefrontCountryCodeIOS, other.StorefrontCountryCodeIOS) + && EqualityComparer.Default.Equals(StoreId, other.StoreId) + && EqualityComparer.Default.Equals(SubscriptionGroupIdIOS, other.SubscriptionGroupIdIOS) + && EqualityComparer.Default.Equals(TransactionDate, other.TransactionDate) + && EqualityComparer.Default.Equals(TransactionId, other.TransactionId) + && EqualityComparer.Default.Equals(TransactionReasonIOS, other.TransactionReasonIOS) + && EqualityComparer.Default.Equals(WebOrderLineItemIdIOS, other.WebOrderLineItemIdIOS); + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(typeof(PurchaseIOS)); + hash.Add(AdvancedCommerceInfoIOS); + hash.Add(AppAccountToken); + hash.Add(AppBundleIdIOS); + hash.Add(BillingPlanTypeIOS); + hash.Add(BundleOriginalTransactionIdIOS); + hash.Add(BundleProductIdIOS); + hash.Add(BundleSubscriptionGroupIdIOS); + hash.Add(BundleTransactionIdIOS); + hash.Add(CommitmentInfoIOS); + hash.Add(CountryCodeIOS); + hash.Add(CurrencyCodeIOS); + hash.Add(CurrencySymbolIOS); + hash.Add(CurrentPlanId); + hash.Add(EnvironmentIOS); + hash.Add(ExpirationDateIOS); + hash.Add(Id); + hash.Add(Ids); + hash.Add(IsAutoRenewing); + hash.Add(IsUpgradedIOS); + hash.Add(OfferIOS); + hash.Add(OriginalTransactionDateIOS); + hash.Add(OriginalTransactionIdentifierIOS); + hash.Add(OwnershipTypeIOS); + hash.Add(PreviousOriginalTransactionIdIOS); + hash.Add(ProductId); + hash.Add(PurchaseState); + hash.Add(PurchaseToken); + hash.Add(Quantity); + hash.Add(QuantityIOS); + hash.Add(ReasonIOS); + hash.Add(ReasonStringRepresentationIOS); + hash.Add(RenewalInfoIOS); + hash.Add(RevocationDateIOS); + hash.Add(RevocationReasonIOS); + hash.Add(RevocationTypeIOS); + hash.Add(Store); + hash.Add(StorefrontCountryCodeIOS); + hash.Add(StoreId); + hash.Add(SubscriptionGroupIdIOS); + hash.Add(TransactionDate); + hash.Add(TransactionId); + hash.Add(TransactionReasonIOS); + hash.Add(WebOrderLineItemIdIOS); + return hash.ToHashCode(); + } } public sealed record PurchaseOfferIOS @@ -3808,7 +4004,7 @@ public sealed record RequestPurchaseResultPurchase(Purchase? Value) : RequestPur public sealed record RequestPurchaseResultPurchases(IReadOnlyList? Value) : RequestPurchaseResult; -public sealed record RequestVerifyPurchaseWithIapkitResult +public sealed record RequestVerifyPurchaseWithIapkitResult : IJsonOnDeserialized { /// /// Available in OpenIAP 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. @@ -3850,6 +4046,51 @@ public sealed record RequestVerifyPurchaseWithIapkitResult public required IapkitPurchaseState State { get; init; } [JsonPropertyName("store")] public required IapStore Store { get; init; } + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + private string? _storeId; + [JsonPropertyName("storeId")] + public string StoreId { get => StoreIdentity.Resolve(Store, _storeId); init => _storeId = value; } + void IJsonOnDeserialized.OnDeserialized() => _storeId = StoreIdentity.Resolve(Store, _storeId); + public bool Equals(RequestVerifyPurchaseWithIapkitResult? other) => other is not null + && EqualityComparer.Default.Equals(ClientPayload, other.ClientPayload) + && EqualityComparer.Default.Equals(Environment, other.Environment) + && EqualityComparer.Default.Equals(IsValid, other.IsValid) + && EqualityComparer.Default.Equals(ProductId, other.ProductId) + && EqualityComparer.Default.Equals(State, other.State) + && EqualityComparer.Default.Equals(Store, other.Store) + && EqualityComparer.Default.Equals(StoreId, other.StoreId); + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(typeof(RequestVerifyPurchaseWithIapkitResult)); + hash.Add(ClientPayload); + hash.Add(Environment); + hash.Add(IsValid); + hash.Add(ProductId); + hash.Add(State); + hash.Add(Store); + hash.Add(StoreId); + return hash.ToHashCode(); + } +} + +/// +/// Store-provider contract shared by the Apple and Android native bindings. +/// coreVersion names the native contract build; clientProtocolVersion names the +/// Client Protocol build. Capabilities use the conformance provider profile ids. +/// +public sealed record StoreProviderDescriptor +{ + [JsonPropertyName("capabilities")] + public required IReadOnlyList Capabilities { get; init; } + [JsonPropertyName("clientProtocolVersion")] + public required string ClientProtocolVersion { get; init; } + [JsonPropertyName("coreVersion")] + public required string CoreVersion { get; init; } + [JsonPropertyName("platform")] + public required IapPlatform Platform { get; init; } + [JsonPropertyName("storeId")] + public required string StoreId { get; init; } } public sealed record SubscriptionCommitmentInfoIOS @@ -4536,10 +4777,8 @@ public void Validate() /// Platform-specific purchase request parameters. /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. -/// - apple: Always targets App Store -/// - google: Targets Play Store by default, Horizon when built with horizon flavor, -/// or Fire OS when built with amazon flavor -/// (determined at build time, not runtime) +/// - apple: Uses the selected Apple-platform provider (App Store by default) +/// - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) /// public sealed record RequestPurchasePropsByPlatforms { @@ -4659,10 +4898,8 @@ public sealed record RequestSubscriptionIosProps /// Platform-specific subscription request parameters. /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. -/// - apple: Always targets App Store -/// - google: Targets Play Store by default, Horizon when built with horizon flavor, -/// or Fire OS when built with amazon flavor -/// (determined at build time, not runtime) +/// - apple: Uses the selected Apple-platform provider (App Store by default) +/// - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) /// public sealed record RequestSubscriptionPropsByPlatforms { @@ -5043,6 +5280,7 @@ public interface MutationResolver /// /// Initiate a purchase or subscription flow; rely on events for final state. + /// Providers emit one canonical purchase-error event before returning or throwing a request failure. /// See: https://openiap.dev/docs/apis/request-purchase /// Task RequestPurchaseAsync(RequestPurchaseProps @params); diff --git a/specs/client/src/generated/Types.kt b/specs/client/src/generated/Types.kt index 9167dd685..91fa15823 100644 --- a/specs/client/src/generated/Types.kt +++ b/specs/client/src/generated/Types.kt @@ -6,6 +6,27 @@ // Generated JSON decoders use unchecked casts for nested wire values. @file:Suppress("UNCHECKED_CAST") +public object StoreIds { + const val Apple = "apple" + const val Play = "play" + const val Horizon = "horizon" + const val Amazon = "amazon" +} + +private fun resolveStoreId(store: IapStore, value: Any?): String { + val official = when (store) { + IapStore.Apple -> "apple" + IapStore.Google -> "play" + IapStore.Horizon -> "horizon" + IapStore.Amazon -> "amazon" + IapStore.Unknown -> null + } + require(value == null || value is String) { "storeId must be a string" } + val id = value as String? ?: official + require(id != null && if (official != null) id == official else id.matches(Regex("[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*")) && id !in setOf("auto", "none", "unknown", "apple", "play", "google", "horizon", "amazon")) { "Invalid store identity" } + return id +} + // MARK: - Enums /** @@ -792,6 +813,9 @@ public enum class IapPlatform(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Frozen legacy store discriminator. Use storeId for extensible store identity. + */ public enum class IapStore(val rawValue: String) { Unknown("unknown"), Apple("apple"), @@ -1436,6 +1460,9 @@ public interface PurchaseCommon { val currentPlanId: String? val id: String val ids: List? + /** + * Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. + */ val isAutoRenewing: Boolean val productId: String val purchaseState: PurchaseState @@ -1448,6 +1475,10 @@ public interface PurchaseCommon { * Store where purchase was made */ val store: IapStore + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + val storeId: String /** * Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -1467,6 +1498,9 @@ public interface VerifyPurchaseResultCommon { // MARK: - Objects public data class ActiveSubscription( + /** + * Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. + */ val autoRenewingAndroid: Boolean? = null, val basePlanIdAndroid: String? = null, /** @@ -2981,6 +3015,9 @@ public data class ProductSubscriptionIOS( } public data class PurchaseAndroid( + /** + * Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. + */ val autoRenewingAndroid: Boolean? = null, override val currentPlanId: String? = null, val dataAndroid: String? = null, @@ -2988,6 +3025,9 @@ public data class PurchaseAndroid( override val id: String, override val ids: List? = null, val isAcknowledgedAndroid: Boolean? = null, + /** + * Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. + */ override val isAutoRenewing: Boolean, /** * Whether the subscription is suspended (Android) @@ -3016,6 +3056,10 @@ public data class PurchaseAndroid( * Store where purchase was made */ override val store: IapStore, + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + override val storeId: String, /** * Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -3036,6 +3080,7 @@ public data class PurchaseAndroid( companion object { fun fromJson(json: Map): PurchaseAndroid { + val store = IapStore.fromJson(json["store"] as? String ?: "") return PurchaseAndroid( autoRenewingAndroid = json["autoRenewingAndroid"] as? Boolean, currentPlanId = json["currentPlanId"] as? String, @@ -3055,7 +3100,8 @@ public data class PurchaseAndroid( 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 = store, + storeId = resolveStoreId(store, json["storeId"]), transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String, userIdAmazon = json["userIdAmazon"] as? String, @@ -3085,6 +3131,7 @@ public data class PurchaseAndroid( "quantity" to quantity, "signatureAndroid" to signatureAndroid, "store" to store.toJson(), + "storeId" to storeId, "transactionDate" to transactionDate, "transactionId" to transactionId, "userIdAmazon" to userIdAmazon, @@ -3201,6 +3248,9 @@ public data class PurchaseIOS( val expirationDateIOS: Double? = null, override val id: String, override val ids: List? = null, + /** + * Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. + */ override val isAutoRenewing: Boolean, val isUpgradedIOS: Boolean? = null, val offerIOS: PurchaseOfferIOS? = null, @@ -3238,6 +3288,10 @@ public data class PurchaseIOS( */ override val store: IapStore, val storefrontCountryCodeIOS: String? = null, + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + override val storeId: String, val subscriptionGroupIdIOS: String? = null, /** * Unix timestamp in milliseconds since January 1, 1970 UTC. @@ -3250,6 +3304,7 @@ public data class PurchaseIOS( companion object { fun fromJson(json: Map): PurchaseIOS { + val store = IapStore.fromJson(json["store"] as? String ?: "") return PurchaseIOS( advancedCommerceInfoIOS = (json["advancedCommerceInfoIOS"] as? Map)?.let { AdvancedCommerceInfoIOS.fromJson(it) }, appAccountToken = json["appAccountToken"] as? String, @@ -3286,8 +3341,9 @@ public data class PurchaseIOS( revocationDateIOS = (json["revocationDateIOS"] as? Number)?.toDouble(), revocationReasonIOS = json["revocationReasonIOS"] as? String, revocationTypeIOS = json["revocationTypeIOS"] as? String, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = store, storefrontCountryCodeIOS = json["storefrontCountryCodeIOS"] as? String, + storeId = resolveStoreId(store, json["storeId"]), subscriptionGroupIdIOS = json["subscriptionGroupIdIOS"] as? String, transactionDate = (json["transactionDate"] as? Number)?.toDouble() ?: 0.0, transactionId = json["transactionId"] as? String ?: "", @@ -3336,6 +3392,7 @@ public data class PurchaseIOS( "revocationTypeIOS" to revocationTypeIOS, "store" to store.toJson(), "storefrontCountryCodeIOS" to storefrontCountryCodeIOS, + "storeId" to storeId, "subscriptionGroupIdIOS" to subscriptionGroupIdIOS, "transactionDate" to transactionDate, "transactionId" to transactionId, @@ -3577,7 +3634,7 @@ public data class RequestPurchaseResultPurchase(val value: Purchase?) : RequestP public data class RequestPurchaseResultPurchases(val value: List?) : RequestPurchaseResult -public data class RequestVerifyPurchaseWithIapkitResult( +public class RequestVerifyPurchaseWithIapkitResult private constructor( /** * True when the purchase is valid and actionable. * Only entitled, pending-acknowledgment, or ready-to-consume return true. @@ -3589,7 +3646,8 @@ public data class RequestVerifyPurchaseWithIapkitResult( * The current state of the purchase. */ val state: IapkitPurchaseState, - val store: IapStore + val store: IapStore, + explicitStoreId: String?, ) { /** @@ -3621,6 +3679,24 @@ public data class RequestVerifyPurchaseWithIapkitResult( var environment: String? = null private set + /** + * Stable store id: apple, play, horizon, amazon, or a community provider id. + */ + var storeId: String = explicitStoreId?.let { resolveStoreId(store, it) } ?: when (store) { IapStore.Apple -> StoreIds.Apple; IapStore.Google -> StoreIds.Play; IapStore.Horizon -> StoreIds.Horizon; IapStore.Amazon -> StoreIds.Amazon; IapStore.Unknown -> throw IllegalArgumentException("RequestVerifyPurchaseWithIapkitResult with store Unknown requires an explicit valid storeId") } + private set + + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + ) : this( + isValid = isValid, + state = state, + store = store, + explicitStoreId = null, + ) { + } + constructor( isValid: Boolean, state: IapkitPurchaseState, @@ -3631,6 +3707,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( isValid = isValid, state = state, store = store, + explicitStoreId = null, ) { this.clientPayload = clientPayload this.productId = productId @@ -3647,21 +3724,80 @@ public data class RequestVerifyPurchaseWithIapkitResult( isValid = isValid, state = state, store = store, + explicitStoreId = null, + ) { + this.clientPayload = clientPayload + this.productId = productId + this.environment = environment + } + + constructor( + isValid: Boolean, + state: IapkitPurchaseState, + store: IapStore, + clientPayload: IapkitProductClientPayload? = null, + productId: String? = null, + environment: String? = null, + storeId: String, + ) : this( + isValid = isValid, + state = state, + store = store, + explicitStoreId = storeId, ) { this.clientPayload = clientPayload this.productId = productId this.environment = environment } + operator fun component1(): Boolean = isValid + operator fun component2(): IapkitPurchaseState = state + operator fun component3(): IapStore = store + + fun copy( + isValid: Boolean = this.isValid, + state: IapkitPurchaseState = this.state, + store: IapStore = this.store, + storeId: String? = null, + ): RequestVerifyPurchaseWithIapkitResult { + val resolvedStoreId = storeId?.let { resolveStoreId(store, it) } ?: if (store == this.store) this.storeId else when (store) { IapStore.Apple -> StoreIds.Apple; IapStore.Google -> StoreIds.Play; IapStore.Horizon -> StoreIds.Horizon; IapStore.Amazon -> StoreIds.Amazon; IapStore.Unknown -> throw IllegalArgumentException("RequestVerifyPurchaseWithIapkitResult with store Unknown requires an explicit valid storeId") } + return RequestVerifyPurchaseWithIapkitResult( + isValid = isValid, + state = state, + store = store, + clientPayload = this.clientPayload, + productId = this.productId, + environment = this.environment, + storeId = resolvedStoreId, + ) + } + + override fun equals(other: Any?): Boolean = other is RequestVerifyPurchaseWithIapkitResult && + isValid == other.isValid && state == other.state && store == other.store && clientPayload == other.clientPayload && productId == other.productId && environment == other.environment && storeId == other.storeId + override fun hashCode(): Int { + var result = 1 + result = 31 * result + isValid.hashCode() + result = 31 * result + state.hashCode() + result = 31 * result + store.hashCode() + result = 31 * result + (clientPayload?.hashCode() ?: 0) + result = 31 * result + (productId?.hashCode() ?: 0) + result = 31 * result + (environment?.hashCode() ?: 0) + result = 31 * result + storeId.hashCode() + return result + } + override fun toString(): String = "RequestVerifyPurchaseWithIapkitResult(isValid=$isValid, state=$state, store=$store, clientPayload=$clientPayload, productId=$productId, environment=$environment, storeId=$storeId)" + companion object { fun fromJson(json: Map): RequestVerifyPurchaseWithIapkitResult { + val store = IapStore.fromJson(json["store"] as? String ?: "") return RequestVerifyPurchaseWithIapkitResult( isValid = json["isValid"] as? Boolean ?: false, state = (json["state"] as? String)?.let { IapkitPurchaseState.fromJson(it) } ?: IapkitPurchaseState.Unknown, - store = (json["store"] as? String)?.let { IapStore.fromJson(it) } ?: IapStore.Unknown, + store = store, clientPayload = (json["clientPayload"] as? Map)?.let { runCatching { IapkitProductClientPayload.fromJson(it) }.getOrNull() }, productId = json["productId"] as? String, environment = json["environment"] as? String, + storeId = resolveStoreId(store, json["storeId"]), ) } } @@ -3669,6 +3805,7 @@ public data class RequestVerifyPurchaseWithIapkitResult( fun toJson(): Map = mapOf( "__typename" to "RequestVerifyPurchaseWithIapkitResult", "store" to store.toJson(), + "storeId" to storeId, "environment" to environment, "isValid" to isValid, "state" to state.toJson(), @@ -3677,6 +3814,41 @@ public data class RequestVerifyPurchaseWithIapkitResult( ) } +/** + * Store-provider contract shared by the Apple and Android native bindings. + * coreVersion names the native contract build; clientProtocolVersion names the + * Client Protocol build. Capabilities use the conformance provider profile ids. + */ +public data class StoreProviderDescriptor( + val capabilities: List, + val clientProtocolVersion: String, + val coreVersion: String, + val platform: IapPlatform, + val storeId: String +) { + + companion object { + fun fromJson(json: Map): StoreProviderDescriptor { + return StoreProviderDescriptor( + capabilities = (json["capabilities"] as? List<*>)?.mapNotNull { it as? String } ?: throw IllegalArgumentException("Missing capabilities for StoreProviderDescriptor"), + clientProtocolVersion = (json["clientProtocolVersion"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank clientProtocolVersion for StoreProviderDescriptor"), + coreVersion = (json["coreVersion"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank coreVersion for StoreProviderDescriptor"), + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: throw IllegalArgumentException("Missing required enum value for IapPlatform"), + storeId = (json["storeId"] as? String)?.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Missing or blank storeId for StoreProviderDescriptor"), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "StoreProviderDescriptor", + "capabilities" to capabilities, + "clientProtocolVersion" to clientProtocolVersion, + "coreVersion" to coreVersion, + "platform" to platform.toJson(), + "storeId" to storeId, + ) +} + public data class SubscriptionCommitmentInfoIOS( val displayPrice: String, val period: SubscriptionPeriodValueIOS, @@ -4930,10 +5102,8 @@ public data class RequestPurchaseProps( * Platform-specific purchase request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ public data class RequestPurchasePropsByPlatforms( /** @@ -5134,10 +5304,8 @@ public data class RequestSubscriptionIosProps( * Platform-specific subscription request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ public data class RequestSubscriptionPropsByPlatforms( /** @@ -5873,6 +6041,7 @@ public interface MutationResolver { suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ suspend fun requestPurchase(params: RequestPurchaseProps): RequestPurchaseResult? @@ -6279,6 +6448,7 @@ public data class MutationHandlers( val presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = null, /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ val requestPurchase: MutationRequestPurchaseHandler? = null, diff --git a/specs/client/src/generated/Types.swift b/specs/client/src/generated/Types.swift index c9ad6cb10..1d88f1edc 100644 --- a/specs/client/src/generated/Types.swift +++ b/specs/client/src/generated/Types.swift @@ -5,6 +5,29 @@ import Foundation +public enum StoreIds { + public static let Apple = "apple" + public static let Play = "play" + public static let Horizon = "horizon" + public static let Amazon = "amazon" +} + +private func resolveStoreId(_ store: IapStore, _ value: String?) -> String? { + let official: String? + switch store { + case .apple: official = "apple" + case .google: official = "play" + case .horizon: official = "horizon" + case .amazon: official = "amazon" + case .unknown: official = nil + } + guard let id = value ?? official else { return nil } + if let official { return id == official ? id : nil } + guard id.range(of: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", options: .regularExpression) == id.startIndex.. ExternalPurchaseNoticeResultIOS /// Initiate a purchase or subscription flow; rely on events for final state. + /// Providers emit one canonical purchase-error event before returning or throwing a request failure. /// See: https://openiap.dev/docs/apis/request-purchase func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? /// Restore non-consumable and active subscription purchases. diff --git a/specs/client/src/generated/types.dart b/specs/client/src/generated/types.dart index 913b0c81c..9345facb8 100644 --- a/specs/client/src/generated/types.dart +++ b/specs/client/src/generated/types.dart @@ -7,6 +7,28 @@ import 'dart:async'; +abstract final class StoreIds { + static const String apple = 'apple'; + static const String play = 'play'; + static const String horizon = 'horizon'; + static const String amazon = 'amazon'; +} + +String _resolveStoreId(IapStore store, dynamic value) { + final official = switch (store) { + IapStore.Apple => 'apple', + IapStore.Google => 'play', + IapStore.Horizon => 'horizon', + IapStore.Amazon => 'amazon', + IapStore.Unknown => null, + }; + final id = value ?? official; + if (id is! String || (official != null ? id != official : RegExp(r'^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$').firstMatch(id)?.end != id.length || const {'auto', 'none', 'unknown', 'apple', 'play', 'google', 'horizon', 'amazon'}.contains(id))) { + throw const FormatException('Invalid store identity'); + } + return id; +} + // MARK: - Enums /// Play Billing choice image layout (Android) @@ -803,6 +825,7 @@ enum IapPlatform { String toJson() => value; } +/// Frozen legacy store discriminator. Use storeId for extensible store identity. enum IapStore { Unknown('unknown'), Apple('apple'), @@ -1518,6 +1541,7 @@ abstract class PurchaseCommon { String? get currentPlanId; String get id; List? get ids; + /// Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. bool get isAutoRenewing; String get productId; PurchaseState get purchaseState; @@ -1526,6 +1550,8 @@ abstract class PurchaseCommon { int get quantity; /// Store where purchase was made IapStore get store; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + String get storeId; /// Unix timestamp in milliseconds since January 1, 1970 UTC. double get transactionDate; } @@ -1555,6 +1581,7 @@ class ActiveSubscription { required this.transactionId, }); + /// Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. final bool? autoRenewingAndroid; final String? basePlanIdAndroid; /// The current plan identifier. This is: @@ -3128,6 +3155,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { required this.quantity, this.signatureAndroid, required this.store, + required this.storeId, required this.transactionDate, this.transactionId, this.userIdAmazon, @@ -3135,6 +3163,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { this.isAlternativeBilling, }); + /// Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. final bool? autoRenewingAndroid; final String? currentPlanId; final String? dataAndroid; @@ -3142,6 +3171,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { final String id; final List? ids; final bool? isAcknowledgedAndroid; + /// Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. final bool isAutoRenewing; /// Whether the subscription is suspended (Android) /// A suspended subscription means the user's payment method failed and they need to fix it. @@ -3164,6 +3194,8 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { final String? signatureAndroid; /// Store where purchase was made final IapStore store; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + final String storeId; /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; final String? transactionId; @@ -3177,6 +3209,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { final bool? isAlternativeBilling; factory PurchaseAndroid.fromJson(Map json) { + final store = IapStore.fromJson(json['store'] as String); return PurchaseAndroid( autoRenewingAndroid: json['autoRenewingAndroid'] as bool?, currentPlanId: json['currentPlanId'] as String?, @@ -3196,7 +3229,8 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { purchaseToken: json['purchaseToken'] as String?, quantity: json['quantity'] as int, signatureAndroid: json['signatureAndroid'] as String?, - store: IapStore.fromJson(json['store'] as String), + store: store, + storeId: _resolveStoreId(store, json['storeId']), transactionDate: (json['transactionDate'] as num).toDouble(), transactionId: json['transactionId'] as String?, userIdAmazon: json['userIdAmazon'] as String?, @@ -3228,6 +3262,7 @@ class PurchaseAndroid extends Purchase implements PurchaseCommon { 'quantity': quantity, 'signatureAndroid': signatureAndroid, 'store': store.toJson(), + 'storeId': storeId, 'transactionDate': transactionDate, 'transactionId': transactionId, 'userIdAmazon': userIdAmazon, @@ -3329,6 +3364,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { this.revocationTypeIOS, required this.store, this.storefrontCountryCodeIOS, + required this.storeId, this.subscriptionGroupIdIOS, required this.transactionDate, required this.transactionId, @@ -3364,6 +3400,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { final double? expirationDateIOS; final String id; final List? ids; + /// Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. final bool isAutoRenewing; final bool? isUpgradedIOS; final PurchaseOfferIOS? offerIOS; @@ -3391,6 +3428,8 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { /// Store where purchase was made final IapStore store; final String? storefrontCountryCodeIOS; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + final String storeId; final String? subscriptionGroupIdIOS; /// Unix timestamp in milliseconds since January 1, 1970 UTC. final double transactionDate; @@ -3400,6 +3439,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { final bool? isAlternativeBilling; factory PurchaseIOS.fromJson(Map json) { + final store = IapStore.fromJson(json['store'] as String); return PurchaseIOS( advancedCommerceInfoIOS: json['advancedCommerceInfoIOS'] != null ? AdvancedCommerceInfoIOS.fromJson(json['advancedCommerceInfoIOS'] as Map) : null, appAccountToken: json['appAccountToken'] as String?, @@ -3436,8 +3476,9 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { revocationDateIOS: (json['revocationDateIOS'] as num?)?.toDouble(), revocationReasonIOS: json['revocationReasonIOS'] as String?, revocationTypeIOS: json['revocationTypeIOS'] as String?, - store: IapStore.fromJson(json['store'] as String), + store: store, storefrontCountryCodeIOS: json['storefrontCountryCodeIOS'] as String?, + storeId: _resolveStoreId(store, json['storeId']), subscriptionGroupIdIOS: json['subscriptionGroupIdIOS'] as String?, transactionDate: (json['transactionDate'] as num).toDouble(), transactionId: json['transactionId'] as String, @@ -3488,6 +3529,7 @@ class PurchaseIOS extends Purchase implements PurchaseCommon { 'revocationTypeIOS': revocationTypeIOS, 'store': store.toJson(), 'storefrontCountryCodeIOS': storefrontCountryCodeIOS, + 'storeId': storeId, 'subscriptionGroupIdIOS': subscriptionGroupIdIOS, 'transactionDate': transactionDate, 'transactionId': transactionId, @@ -3750,6 +3792,7 @@ class RequestVerifyPurchaseWithIapkitResult { this.productId, required this.state, required this.store, + required this.storeId, }); /// Available in OpenIAP 2.4.0 / openiap-apple 2.4.1 / openiap-google 2.4.1. @@ -3777,15 +3820,19 @@ class RequestVerifyPurchaseWithIapkitResult { /// The current state of the purchase. final IapkitPurchaseState state; final IapStore store; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + final String storeId; factory RequestVerifyPurchaseWithIapkitResult.fromJson(Map json) { + final store = IapStore.fromJson(json['store'] as String); return RequestVerifyPurchaseWithIapkitResult( clientPayload: json['clientPayload'] is Map ? IapkitProductClientPayload._tryFromJson(json['clientPayload'] as Map) : null, environment: json['environment'] as String?, isValid: json['isValid'] as bool, productId: json['productId'] as String?, state: IapkitPurchaseState.fromJson(json['state'] as String), - store: IapStore.fromJson(json['store'] as String), + store: store, + storeId: _resolveStoreId(store, json['storeId']), ); } @@ -3798,6 +3845,47 @@ class RequestVerifyPurchaseWithIapkitResult { 'productId': productId, 'state': state.toJson(), 'store': store.toJson(), + 'storeId': storeId, + }; + } +} + +/// Store-provider contract shared by the Apple and Android native bindings. +/// coreVersion names the native contract build; clientProtocolVersion names the +/// Client Protocol build. Capabilities use the conformance provider profile ids. +class StoreProviderDescriptor { + const StoreProviderDescriptor({ + required this.capabilities, + required this.clientProtocolVersion, + required this.coreVersion, + required this.platform, + required this.storeId, + }); + + final List capabilities; + final String clientProtocolVersion; + final String coreVersion; + final IapPlatform platform; + final String storeId; + + factory StoreProviderDescriptor.fromJson(Map json) { + return StoreProviderDescriptor( + capabilities: (json['capabilities'] as List).map((e) => e as String).toList(), + clientProtocolVersion: json['clientProtocolVersion'] as String, + coreVersion: json['coreVersion'] as String, + platform: IapPlatform.fromJson(json['platform'] as String), + storeId: json['storeId'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'StoreProviderDescriptor', + 'capabilities': capabilities, + 'clientProtocolVersion': clientProtocolVersion, + 'coreVersion': coreVersion, + 'platform': platform.toJson(), + 'storeId': storeId, }; } } @@ -5012,10 +5100,8 @@ class _SubsPurchase extends RequestPurchaseProps { /// Platform-specific purchase request parameters. /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. -/// - apple: Always targets App Store -/// - google: Targets Play Store by default, Horizon when built with horizon flavor, -/// or Fire OS when built with amazon flavor -/// (determined at build time, not runtime) +/// - apple: Uses the selected Apple-platform provider (App Store by default) +/// - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) class RequestPurchasePropsByPlatforms { const RequestPurchasePropsByPlatforms({ this.apple, @@ -5190,10 +5276,8 @@ class RequestSubscriptionIosProps { /// Platform-specific subscription request parameters. /// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. -/// - apple: Always targets App Store -/// - google: Targets Play Store by default, Horizon when built with horizon flavor, -/// or Fire OS when built with amazon flavor -/// (determined at build time, not runtime) +/// - apple: Uses the selected Apple-platform provider (App Store by default) +/// - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) class RequestSubscriptionPropsByPlatforms { const RequestSubscriptionPropsByPlatforms({ this.apple, @@ -5749,6 +5833,7 @@ sealed class Purchase implements PurchaseCommon { String get id; @override List? get ids; + /// Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. @override bool get isAutoRenewing; @override @@ -5763,6 +5848,9 @@ sealed class Purchase implements PurchaseCommon { /// Store where purchase was made @override IapStore get store; + /// Stable store id: apple, play, horizon, amazon, or a community provider id. + @override + String get storeId; /// Unix timestamp in milliseconds since January 1, 1970 UTC. @override double get transactionDate; @@ -5916,6 +6004,7 @@ abstract class MutationResolver { /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios Future presentExternalPurchaseNoticeSheetIOS(); /// Initiate a purchase or subscription flow; rely on events for final state. + /// Providers emit one canonical purchase-error event before returning or throwing a request failure. /// See: https://openiap.dev/docs/apis/request-purchase Future requestPurchase(RequestPurchaseProps params); /// Restore non-consumable and active subscription purchases. diff --git a/specs/client/src/generated/types.gd b/specs/client/src/generated/types.gd index 2c89d96fc..21f3459ef 100644 --- a/specs/client/src/generated/types.gd +++ b/specs/client/src/generated/types.gd @@ -12,6 +12,23 @@ # Keep the file named types.gd: the preload finds it by that name. const _Types = preload("types.gd") +class StoreIds: + const APPLE = "apple" + const PLAY = "play" + const HORIZON = "horizon" + const AMAZON = "amazon" + +static func resolve_store_id(store: Variant, value: Variant) -> Variant: + var official: Variant = {IapStore.APPLE: "apple", IapStore.GOOGLE: "play", IapStore.HORIZON: "horizon", IapStore.AMAZON: "amazon"}.get(store) + var id: Variant = value if value != null else official + if not id is String: + return null + if official != null: + return id if id == official else null + var pattern = RegEx.new() + pattern.compile("^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") + return id if pattern.search(id) != null and pattern.search(id).get_string() == id and not id in ["auto", "none", "unknown", "apple", "play", "google", "horizon", "amazon"] else null + # ============================================================================ # Enums # ============================================================================ @@ -208,6 +225,7 @@ enum IapPlatform { ANDROID = 1, } +## Frozen legacy store discriminator. Use storeId for extensible store identity. enum IapStore { UNKNOWN = 0, APPLE = 1, @@ -364,6 +382,7 @@ class ActiveSubscription: var product_id: String = "" var is_active: bool = false var expiration_date_ios: Variant = null + ## Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. var auto_renewing_android: Variant = null var environment_ios: Variant = null var days_until_expiration_ios: Variant = null @@ -2254,12 +2273,16 @@ class PurchaseAndroid: var purchase_token: Variant = null ## Store where purchase was made var store: _Types.IapStore = IapStore.UNKNOWN + ## Stable store id: apple, play, horizon, amazon, or a community provider id. + var store_id: String = "" var quantity: int = 0 var purchase_state: _Types.PurchaseState = PurchaseState.UNKNOWN + ## Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. var is_auto_renewing: bool = false var current_plan_id: Variant = null var data_android: Variant = null var signature_android: Variant = null + ## Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. var auto_renewing_android: Variant = null var is_acknowledged_android: Variant = null var package_name_android: Variant = null @@ -2343,6 +2366,10 @@ class PurchaseAndroid: obj.user_id_amazon = data["userIdAmazon"] if data.has("userMarketplaceAmazon") and data["userMarketplaceAmazon"] != null: obj.user_marketplace_amazon = data["userMarketplaceAmazon"] + var store_id = _Types.resolve_store_id(obj.store, data.get("storeId")) + if store_id == null: + return null + obj.store_id = store_id return obj func to_dict() -> Dictionary: @@ -2359,6 +2386,7 @@ class PurchaseAndroid: dict["store"] = IAP_STORE_VALUES[store] else: dict["store"] = store + dict["storeId"] = store_id dict["quantity"] = quantity if PURCHASE_STATE_VALUES.has(purchase_state): dict["purchaseState"] = PURCHASE_STATE_VALUES[purchase_state] @@ -2479,8 +2507,11 @@ class PurchaseIOS: var purchase_token: Variant = null ## Store where purchase was made var store: _Types.IapStore = IapStore.UNKNOWN + ## Stable store id: apple, play, horizon, amazon, or a community provider id. + var store_id: String = "" var quantity: int = 0 var purchase_state: _Types.PurchaseState = PurchaseState.UNKNOWN + ## Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. var is_auto_renewing: bool = false var current_plan_id: Variant = null var transaction_id: String = "" @@ -2648,6 +2679,10 @@ class PurchaseIOS: obj.advanced_commerce_info_ios = AdvancedCommerceInfoIOS.from_dict(data["advancedCommerceInfoIOS"]) else: obj.advanced_commerce_info_ios = data["advancedCommerceInfoIOS"] + var store_id = _Types.resolve_store_id(obj.store, data.get("storeId")) + if store_id == null: + return null + obj.store_id = store_id return obj func to_dict() -> Dictionary: @@ -2662,6 +2697,7 @@ class PurchaseIOS: dict["store"] = IAP_STORE_VALUES[store] else: dict["store"] = store + dict["storeId"] = store_id dict["quantity"] = quantity if PURCHASE_STATE_VALUES.has(purchase_state): dict["purchaseState"] = PURCHASE_STATE_VALUES[purchase_state] @@ -2973,6 +3009,8 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: _Types.IapStore = IapStore.UNKNOWN + ## Stable store id: apple, play, horizon, amazon, or a community provider id. + var store_id: String = "" ## Available in OpenIAP 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. Deliberately String, not an enum: the value space belongs to IAPKit and the stores behind it, and Apple's App Store Server alone also names `Xcode` and `LocalTesting`. SDKs must forward this value opaquely. Never reject a verification because the environment is unrecognised — that fails a purchase the store already confirmed. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. @@ -3011,6 +3049,10 @@ class RequestVerifyPurchaseWithIapkitResult: if data.has("clientPayload") and data["clientPayload"] != null: if data["clientPayload"] is Dictionary: obj.client_payload = IapkitProductClientPayload.from_dict_or_null(data["clientPayload"]) + var store_id = _Types.resolve_store_id(obj.store, data.get("storeId")) + if store_id == null: + return null + obj.store_id = store_id return obj func to_dict() -> Dictionary: @@ -3019,6 +3061,7 @@ class RequestVerifyPurchaseWithIapkitResult: dict["store"] = IAP_STORE_VALUES[store] else: dict["store"] = store + dict["storeId"] = store_id if environment != null: dict["environment"] = environment dict["isValid"] = is_valid @@ -3034,6 +3077,55 @@ class RequestVerifyPurchaseWithIapkitResult: dict["clientPayload"] = client_payload return dict +## Store-provider contract shared by the Apple and Android native bindings. coreVersion names the native contract build; clientProtocolVersion names the Client Protocol build. Capabilities use the conformance provider profile ids. +class StoreProviderDescriptor: + var store_id: String = "" + var platform: _Types.IapPlatform + var core_version: String = "" + var client_protocol_version: String = "" + var capabilities: Array[String] = [] + + static func from_dict(data: Dictionary, report_errors: bool = true) -> _Types.StoreProviderDescriptor: + if not data.has("platform") or not ((data["platform"] is String and IAP_PLATFORM_FROM_STRING.has(data["platform"])) or (data["platform"] is int and IAP_PLATFORM_VALUES.has(data["platform"]))): + if report_errors: + push_error("Invalid StoreProviderDescriptor.platform enum value") + return null + var obj = StoreProviderDescriptor.new() + if data.has("storeId") and data["storeId"] != null: + obj.store_id = data["storeId"] + if data.has("platform") and data["platform"] != null: + var enum_str = data["platform"] + if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str): + obj.platform = IAP_PLATFORM_FROM_STRING[enum_str] + elif enum_str is int and IAP_PLATFORM_VALUES.has(enum_str): + obj.platform = enum_str + else: + obj.platform = enum_str + if data.has("coreVersion") and data["coreVersion"] != null: + obj.core_version = data["coreVersion"] + if data.has("clientProtocolVersion") and data["clientProtocolVersion"] != null: + obj.client_protocol_version = data["clientProtocolVersion"] + if data.has("capabilities") and data["capabilities"] != null: + if data["capabilities"] is Array: + var arr: Array[String] = [] + for item in data["capabilities"]: + if item is String: + arr.append(str(item)) + obj.capabilities = arr + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["storeId"] = store_id + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + dict["coreVersion"] = core_version + dict["clientProtocolVersion"] = client_protocol_version + dict["capabilities"] = capabilities + return dict + class SubscriptionCommitmentInfoIOS: var display_price: String = "" var period: _Types.SubscriptionPeriodValueIOS @@ -4307,6 +4399,7 @@ class PurchaseInput: var store: Variant = null var quantity: int = 0 var purchase_state: _Types.PurchaseState = PurchaseState.UNKNOWN + ## Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. var is_auto_renewing: bool = false static func from_dict(data: Dictionary) -> _Types.PurchaseInput: @@ -4658,7 +4751,7 @@ class RequestPurchaseProps: dict["type"] = PRODUCT_QUERY_TYPE_VALUES.get(type, type) return dict -## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Uses the selected Apple-platform provider (App Store by default) - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) class RequestPurchasePropsByPlatforms: ## Apple-specific purchase parameters var apple: _Types.RequestPurchaseIosProps @@ -4967,7 +5060,7 @@ class RequestSubscriptionIosProps: dict["advancedCommerceData"] = advanced_commerce_data return dict -## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Uses the selected Apple-platform provider (App Store by default) - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) class RequestSubscriptionPropsByPlatforms: ## Apple-specific subscription parameters var apple: _Types.RequestSubscriptionIosProps @@ -6421,7 +6514,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Initiate a purchase or subscription flow; rely on events for final state. See: https://openiap.dev/docs/apis/request-purchase + ## Initiate a purchase or subscription flow; rely on events for final state. Providers emit one canonical purchase-error event before returning or throwing a request failure. See: https://openiap.dev/docs/apis/request-purchase class requestPurchaseField: const name = "requestPurchase" const snake_name = "request_purchase" @@ -6991,7 +7084,7 @@ static func init_connection_args(config: Variant = null) -> Dictionary: static func end_connection_args() -> Dictionary: return {} -## Initiate a purchase or subscription flow; rely on events for final state. See: https://openiap.dev/docs/apis/request-purchase +## Initiate a purchase or subscription flow; rely on events for final state. Providers emit one canonical purchase-error event before returning or throwing a request failure. See: https://openiap.dev/docs/apis/request-purchase static func request_purchase_args(params: _Types.RequestPurchaseProps) -> Dictionary: var args = {} if params != null: diff --git a/specs/client/src/generated/types.ts b/specs/client/src/generated/types.ts index 54449c7f0..45c72105b 100644 --- a/specs/client/src/generated/types.ts +++ b/specs/client/src/generated/types.ts @@ -3,7 +3,25 @@ // Refresh this file with the generated-types workflow documented for your checkout. // ============================================================================ +export const StoreIds = { + Apple: 'apple', + Play: 'play', + Horizon: 'horizon', + Amazon: 'amazon', +} as const; + +export function resolveStoreId(value: unknown, store: IapStore): string { + const officialIds: Partial> = {'apple': 'apple', 'google': 'play', 'horizon': 'horizon', 'amazon': 'amazon'}; + const official = officialIds[store]; + const id = value ?? official; + if (typeof id !== 'string' || (official != null ? id !== official : store !== 'unknown' || id.match(/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/)?.[0] !== id || ['auto', 'none', 'unknown', 'apple', 'play', 'google', 'horizon', 'amazon'].includes(id))) { + throw new Error('Invalid store identity'); + } + return id; +} + export interface ActiveSubscription { + /** Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. */ autoRenewingAndroid?: (boolean | null); basePlanIdAndroid?: (string | null); /** @@ -586,6 +604,7 @@ export type IapEvent = 'purchase-updated' | 'purchase-error' | 'promoted-product export type IapPlatform = 'ios' | 'android'; +/** Frozen legacy store discriminator. Use storeId for extensible store identity. */ export type IapStore = 'unknown' | 'apple' | 'google' | 'horizon' | 'amazon'; /** Serialization format of a public IAPKit product client payload. */ @@ -856,6 +875,7 @@ export interface Mutation { presentExternalPurchaseNoticeSheetIOS: Promise; /** * Initiate a purchase or subscription flow; rely on events for final state. + * Providers emit one canonical purchase-error event before returning or throwing a request failure. * See: https://openiap.dev/docs/apis/request-purchase */ requestPurchase?: Promise<(Purchase | Purchase[] | null)>; @@ -1203,6 +1223,7 @@ export interface PromotionalOfferJwsInputIOS { export type Purchase = PurchaseAndroid | PurchaseIOS; export interface PurchaseAndroid extends PurchaseCommon { + /** Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement. */ autoRenewingAndroid?: (boolean | null); currentPlanId?: (string | null); dataAndroid?: (string | null); @@ -1210,6 +1231,7 @@ export interface PurchaseAndroid extends PurchaseCommon { id: string; ids?: (string[] | null); isAcknowledgedAndroid?: (boolean | null); + /** Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown. */ isAutoRenewing: boolean; /** * Whether the subscription is suspended (Android) @@ -1236,6 +1258,8 @@ export interface PurchaseAndroid extends PurchaseCommon { signatureAndroid?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; transactionId?: (string | null); @@ -1262,6 +1286,7 @@ export interface PurchaseCommon { currentPlanId?: (string | null); id: string; ids?: (string[] | null); + /** Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions. */ isAutoRenewing: boolean; productId: string; purchaseState: PurchaseState; @@ -1270,6 +1295,8 @@ export interface PurchaseCommon { quantity: number; /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ transactionDate: number; } @@ -1318,6 +1345,7 @@ export interface PurchaseIOS extends PurchaseCommon { expirationDateIOS?: (number | null); id: string; ids?: (string[] | null); + /** Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state. */ isAutoRenewing: boolean; isUpgradedIOS?: (boolean | null); offerIOS?: (PurchaseOfferIOS | null); @@ -1348,6 +1376,8 @@ export interface PurchaseIOS extends PurchaseCommon { revocationTypeIOS?: (string | null); /** Store where purchase was made */ store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; storefrontCountryCodeIOS?: (string | null); subscriptionGroupIdIOS?: (string | null); /** Unix timestamp in milliseconds since January 1, 1970 UTC. */ @@ -1692,10 +1722,8 @@ export type RequestPurchaseProps = * Platform-specific purchase request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ export interface RequestPurchasePropsByPlatforms { /** Apple-specific purchase parameters */ @@ -1792,10 +1820,8 @@ export interface RequestSubscriptionIosProps { * Platform-specific subscription request parameters. * * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - * - apple: Always targets App Store - * - google: Targets Play Store by default, Horizon when built with horizon flavor, - * or Fire OS when built with amazon flavor - * (determined at build time, not runtime) + * - apple: Uses the selected Apple-platform provider (App Store by default) + * - google: Uses the selected Android provider (Play, Horizon, Amazon, or community) */ export interface RequestSubscriptionPropsByPlatforms { /** Apple-specific subscription parameters */ @@ -1905,6 +1931,21 @@ export interface RequestVerifyPurchaseWithIapkitResult { /** The current state of the purchase. */ state: IapkitPurchaseState; store: IapStore; + /** Stable store id: apple, play, horizon, amazon, or a community provider id. */ + storeId: string; +} + +/** + * Store-provider contract shared by the Apple and Android native bindings. + * coreVersion names the native contract build; clientProtocolVersion names the + * Client Protocol build. Capabilities use the conformance provider profile ids. + */ +export interface StoreProviderDescriptor { + capabilities: string[]; + clientProtocolVersion: string; + coreVersion: string; + platform: IapPlatform; + storeId: string; } /** diff --git a/specs/client/src/schema-store-identity.test.ts b/specs/client/src/schema-store-identity.test.ts new file mode 100644 index 000000000..f3bb692ad --- /dev/null +++ b/specs/client/src/schema-store-identity.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs'; +import { Kind, parse, type TypeNode } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { SCHEMA_FILE_NAMES } from '../schema-files.mjs'; + +function isRequiredNamed(type: TypeNode, name: string): boolean { + return ( + type.kind === Kind.NON_NULL_TYPE && + type.type.kind === Kind.NAMED_TYPE && + type.type.name.value === name + ); +} + +function objectFields(sources: string[]): Map> { + const byType = new Map>(); + for (const source of sources) { + for (const definition of parse(source).definitions) { + if ( + definition.kind !== Kind.OBJECT_TYPE_DEFINITION && + definition.kind !== Kind.OBJECT_TYPE_EXTENSION + ) { + continue; + } + let fields = byType.get(definition.name.value); + if (!fields) { + fields = new Map(); + byType.set(definition.name.value, fields); + } + for (const field of definition.fields ?? []) { + fields.set(field.name.value, field.type); + } + } + } + return byType; +} + +function collectViolations(sources: string[]): string[] { + const violations: string[] = []; + for (const [typeName, fields] of objectFields(sources)) { + const store = fields.get('store'); + if (store && isRequiredNamed(store, 'IapStore')) { + const storeId = fields.get('storeId'); + if (!storeId || !isRequiredNamed(storeId, 'String')) { + violations.push(typeName); + } + } + } + return violations.sort(); +} + +function realSources(): string[] { + return SCHEMA_FILE_NAMES.map((fileName) => + readFileSync(new URL(`./${fileName}`, import.meta.url), 'utf8'), + ); +} + +describe('store identity invariant', () => { + it('requires storeId alongside a required store on every object type', () => { + expect(collectViolations(realSources())).toEqual([]); + const withStore = [...objectFields(realSources())] + .filter(([, fields]) => { + const store = fields.get('store'); + return store ? isRequiredNamed(store, 'IapStore') : false; + }) + .map(([typeName]) => typeName); + expect(withStore).toEqual( + expect.arrayContaining([ + 'PurchaseAndroid', + 'PurchaseIOS', + 'RequestVerifyPurchaseWithIapkitResult', + ]), + ); + }); + + it('exempts inputs such as PurchaseInput', () => { + expect(collectViolations([`input PurchaseInput { store: IapStore! }`])).toEqual( + [], + ); + }); + + it('fails when an object type omits storeId', () => { + expect(collectViolations([`type Widget { store: IapStore! }`])).toEqual([ + 'Widget', + ]); + expect( + collectViolations([`type Widget { store: IapStore! storeId: String! }`]), + ).toEqual([]); + }); +}); diff --git a/specs/client/src/store-ids.test.ts b/specs/client/src/store-ids.test.ts new file mode 100644 index 000000000..2c0ee8db7 --- /dev/null +++ b/specs/client/src/store-ids.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./store-registry.json", () => ({ + default: { + stores: [{ id: "any" }, { id: "self" }, { id: "type" }, { id: "community-store" }], + }, +})); + +import { renderStoreIds } from "../codegen/core/store-ids.js"; + +describe("store identity constants", () => { + it("escapes registered ids that become Swift keywords", () => { + const swift = renderStoreIds("swift"); + expect(swift).toContain('public static let `Any` = "any"'); + expect(swift).toContain('public static let `Self` = "self"'); + expect(swift).toContain('public static let `Type` = "type"'); + expect(swift).toContain( + 'public static let CommunityStore = "community-store"', + ); + }); + + it("keeps the same wire id in every language", () => { + for (const language of [ + "typescript", + "kotlin", + "swift", + "dart", + "gdscript", + "csharp", + ] as const) { + expect(renderStoreIds(language)).toContain("community-store"); + } + }); +}); diff --git a/specs/client/src/store-registry.json b/specs/client/src/store-registry.json new file mode 100644 index 000000000..dc9f098ea --- /dev/null +++ b/specs/client/src/store-registry.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "maintenanceWindowDays": 90, + "selectionAliases": {"auto": "auto", "none": "none"}, + "stores": [ + { + "id": "apple", + "platform": "ios", + "displayName": "Apple App Store", + "tier": "official", + "aliases": [], + "maintainers": ["hyodotdev"], + "repo": "https://github.com/hyodotdev/openiap/tree/main/packages/apple", + "coordinates": {"repository": "hyodotdev/openiap", "versionKey": "apple"}, + "capabilities": ["pendingPurchases", "subscriptionBillingIssue", "offerCodeRedemption"], + "latestReport": null + }, + { + "id": "play", + "platform": "android", + "displayName": "Google Play", + "tier": "official", + "aliases": ["google", "gplay", "googleplay", "google-play", "gms"], + "maintainers": ["hyodotdev"], + "repo": "https://github.com/hyodotdev/openiap/tree/main/packages/google", + "coordinates": {"module": "io.github.hyochan.openiap:openiap-google", "versionKey": "google"}, + "capabilities": ["pendingPurchases", "subscriptionBillingIssue", "offerCodeRedemption"], + "latestReport": null + }, + { + "id": "horizon", + "platform": "android", + "displayName": "Meta Horizon", + "tier": "official", + "aliases": ["meta", "quest"], + "maintainers": ["hyodotdev"], + "repo": "https://github.com/hyodotdev/openiap/tree/main/packages/google", + "coordinates": {"module": "io.github.hyochan.openiap:openiap-google-horizon", "versionKey": "google"}, + "capabilities": ["pendingPurchases"], + "latestReport": null + }, + { + "id": "amazon", + "platform": "android", + "displayName": "Amazon Appstore", + "tier": "official", + "aliases": ["fire", "fireos", "fire-os"], + "maintainers": ["hyodotdev"], + "repo": "https://github.com/hyodotdev/openiap/tree/main/packages/google", + "coordinates": {"module": "io.github.hyochan.openiap:openiap-google-amazon", "versionKey": "google"}, + "capabilities": ["pendingPurchases"], + "latestReport": null + } + ] +} diff --git a/specs/client/src/type-android.graphql b/specs/client/src/type-android.graphql index 49aa5c7d7..ea772c448 100644 --- a/specs/client/src/type-android.graphql +++ b/specs/client/src/type-android.graphql @@ -244,14 +244,18 @@ type PurchaseAndroid implements PurchaseCommon { Store where purchase was made """ store: IapStore! + """Stable store id: apple, play, horizon, amazon, or a community provider id.""" + storeId: String! quantity: Int! purchaseState: PurchaseState! + """Legacy Boolean renewal hint. Set false when the store cannot report renewal; keep autoRenewingAndroid null to preserve unknown.""" isAutoRenewing: Boolean! currentPlanId: String # Android-specific fields dataAndroid: String signatureAndroid: String + """Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement.""" autoRenewingAndroid: Boolean isAcknowledgedAndroid: Boolean packageNameAndroid: String diff --git a/specs/client/src/type-ios.graphql b/specs/client/src/type-ios.graphql index fd15a2042..991f8baaa 100644 --- a/specs/client/src/type-ios.graphql +++ b/specs/client/src/type-ios.graphql @@ -203,8 +203,11 @@ type PurchaseIOS implements PurchaseCommon { Store where purchase was made """ store: IapStore! + """Stable store id: apple, play, horizon, amazon, or a community provider id.""" + storeId: String! quantity: Int! purchaseState: PurchaseState! + """Legacy Boolean renewal hint; use renewalInfoIOS or backend status for reported renewal state.""" isAutoRenewing: Boolean! currentPlanId: String diff --git a/specs/client/src/type.graphql b/specs/client/src/type.graphql index 3d1113edb..3a59dfa1b 100644 --- a/specs/client/src/type.graphql +++ b/specs/client/src/type.graphql @@ -6,6 +6,19 @@ enum IapPlatform { Android } +""" +Store-provider contract shared by the Apple and Android native bindings. +coreVersion names the native contract build; clientProtocolVersion names the +Client Protocol build. Capabilities use the conformance provider profile ids. +""" +type StoreProviderDescriptor { + storeId: String! + platform: IapPlatform! + coreVersion: String! + clientProtocolVersion: String! + capabilities: [String!]! +} + # Common product types enum ProductType { InApp @@ -47,6 +60,7 @@ enum PurchaseVerificationProvider { Iapkit } +"""Frozen legacy store discriminator. Use storeId for extensible store identity.""" enum IapStore { Unknown Apple @@ -86,8 +100,11 @@ interface PurchaseCommon { Store where purchase was made """ store: IapStore! + """Stable store id: apple, play, horizon, amazon, or a community provider id.""" + storeId: String! quantity: Int! purchaseState: PurchaseState! + """Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions.""" isAutoRenewing: Boolean! """ The current plan identifier. This is: @@ -199,6 +216,7 @@ input PurchaseInput { store: IapStore quantity: Int! purchaseState: PurchaseState! + """Legacy Boolean renewal hint; it cannot represent unknown. Use nullable platform renewal metadata or backend status for renewal decisions.""" isAutoRenewing: Boolean! } @@ -218,10 +236,8 @@ input DeepLinkOptions { Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. -- apple: Always targets App Store -- google: Targets Play Store by default, Horizon when built with horizon flavor, - or Fire OS when built with amazon flavor - (determined at build time, not runtime) +- apple: Uses the selected Apple-platform provider (App Store by default) +- google: Uses the selected Android provider (Play, Horizon, Amazon, or community) """ input RequestPurchasePropsByPlatforms { """ @@ -238,10 +254,8 @@ input RequestPurchasePropsByPlatforms { Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. -- apple: Always targets App Store -- google: Targets Play Store by default, Horizon when built with horizon flavor, - or Fire OS when built with amazon flavor - (determined at build time, not runtime) +- apple: Uses the selected Apple-platform provider (App Store by default) +- google: Uses the selected Android provider (Play, Horizon, Amazon, or community) """ input RequestSubscriptionPropsByPlatforms { """ @@ -447,6 +461,8 @@ type IapkitProductClientPayload { type RequestVerifyPurchaseWithIapkitResult { store: IapStore! + """Stable store id: apple, play, horizon, amazon, or a community provider id.""" + storeId: String! """ Available in OpenIAP 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or @@ -510,6 +526,7 @@ type ActiveSubscription { productId: String! isActive: Boolean! expirationDateIOS: Float + """Store-reported automatic-renewal status; null when unavailable. This is not proof of entitlement.""" autoRenewingAndroid: Boolean environmentIOS: String daysUntilExpirationIOS: Float diff --git a/specs/client/store-registry.mjs b/specs/client/store-registry.mjs new file mode 100644 index 000000000..682d34d8a --- /dev/null +++ b/specs/client/store-registry.mjs @@ -0,0 +1,334 @@ +import { DART_KEYWORDS } from "./codegen/core/dart-keywords.mjs"; +import { CAPABILITY_MATRIX } from "./src/capability-matrix.mjs"; +import { + SUITE_VERSION, + SUITE_MAJOR_RELEASE_DATE, +} from "../../packages/conformance/src/spec/suite-version.mjs"; +import { + ANDROID_PROVIDER_PROFILE, + requiredProviderBehaviors, +} from "../../packages/conformance/src/spec/android-provider-profile.mjs"; + +export const STORE_ID_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; +export const PROVIDER_COORDINATE_PATTERN = + /^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[0-9][A-Za-z0-9_.-]*$/; +const SEMVER = + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const capabilities = Object.keys(ANDROID_PROVIDER_PROFILE.capabilities); +const discriminators = { + apple: "Apple", + play: "Google", + horizon: "Horizon", + amazon: "Amazon", +}; +const sameSet = (a, b) => + a.length === new Set(a).size && + a.length === b.length && + a.every((item) => b.includes(item)); +const requireThat = (condition, message) => { + if (!condition) throw new Error(message); +}; +const semverMajor = (version) => { + requireThat( + typeof version === "string" && SEMVER.test(version), + `Invalid semantic version: ${version}`, + ); + return Number(version.split(".")[0]); +}; + +export function validateProviderReport( + report, + declaredCapabilities, + { suiteVersion = SUITE_VERSION, platform = "android" } = {}, +) { + requireThat( + ["android", "ios"].includes(platform), + `Unknown provider platform: ${platform}`, + ); + requireThat(report && typeof report === "object", "Missing provider report"); + const major = semverMajor(report.suiteVersion); + const current = semverMajor(suiteVersion); + semverMajor(report.clientProtocolVersion); + requireThat(major <= current, "Report names a future suite major"); + requireThat( + sameSet(report.capabilities ?? [], declaredCapabilities), + "Report capabilities differ from registry", + ); + const scope = platform === "ios" ? "apple-provider" : "android-provider"; + requireThat( + report.scope?.kind === scope, + `Report must use the ${scope} profile`, + ); + const required = report.scope?.requiredBehaviors; + requireThat( + Array.isArray(required) && + required.length > 0 && + required.length === new Set(required).size, + "Missing or duplicate required behaviors", + ); + requireThat( + sameSet( + required, + requiredProviderBehaviors(declaredCapabilities, major, platform), + ), + "Report omits required suite behaviors", + ); + requireThat( + Array.isArray(report.results) && + report.results.length === + new Set(report.results.map((result) => result.id)).size, + "Missing or duplicate report results", + ); + const outcomes = new Map( + report.results.map((result) => [result.id, result.outcome]), + ); + requireThat( + report.results.every( + (result) => + typeof result.id === "string" && + ["pass", "fail", "not-applicable"].includes(result.outcome), + ), + "Invalid report result", + ); + const passing = + required.every((id) => outcomes.get(id) === "pass") && + !report.results.some((result) => result.outcome === "fail"); + requireThat( + report.conformant === passing && + report.scope.complete === required.every((id) => outcomes.has(id)), + "Report verdict contradicts executed results", + ); + return passing; +} + +export function storeBindings(store) { + if (store.bindings) { + requireThat( + typeof store.bindings === "object" && + !Array.isArray(store.bindings) && + Object.keys(store.bindings).length > 0, + `Invalid bindings: ${store.id}`, + ); + requireThat( + ["platform", "coordinates", "capabilities", "latestReport"].every( + (key) => store[key] === undefined, + ), + `Store binding metadata has two owners: ${store.id}`, + ); + return Object.entries(store.bindings).map(([platform, binding]) => ({ + ...binding, + platform, + })); + } + return [ + { + platform: store.platform, + coordinates: store.coordinates, + capabilities: store.capabilities, + latestReport: store.latestReport, + }, + ]; +} + +export function maintenanceStatus( + store, + windowDays, + { + now = new Date(), + suiteVersion = SUITE_VERSION, + majorReleaseDate = SUITE_MAJOR_RELEASE_DATE, + } = {}, +) { + if (store.tier !== "community") return store.tier; + if ( + storeBindings(store).every( + (binding) => + semverMajor(binding.latestReport.report.suiteVersion) === + semverMajor(suiteVersion), + ) + ) + return "community"; + return now.getTime() - new Date(majorReleaseDate).getTime() > + windowDays * 86_400_000 + ? "unmaintained" + : "outdated"; +} + +/** Optional registration adds discovery metadata, never a runtime dependency. */ +export function validateStoreRegistry(registry) { + requireThat( + registry?.schemaVersion === 1 && + Number.isInteger(registry.maintenanceWindowDays) && + registry.maintenanceWindowDays > 0, + "Invalid registry schema or maintenance window", + ); + requireThat( + JSON.stringify(registry.selectionAliases) === + JSON.stringify({ auto: "auto", none: "none" }), + "Selection controls must remain auto and none", + ); + requireThat( + Array.isArray(registry.stores), + "Registry stores must be an array", + ); + const names = new Set(["auto", "none", "unknown"]); + const members = new Set(); + for (const store of registry.stores) { + requireThat( + typeof store.id === "string" && + STORE_ID_PATTERN.test(store.id) && + !names.has(store.id), + `Invalid or duplicate store id: ${store.id}`, + ); + const member = store.id + .split(/[._-]/) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); + const dartMember = member[0].toLowerCase() + member.slice(1); + requireThat( + !members.has(member) && + !DART_KEYWORDS.has(dartMember) && + ![ + "StoreIds", + "Self", + "ToString", + "HashCode", + "RuntimeType", + "NoSuchMethod", + "GetHashCode", + "Equals", + "GetType", + "ReferenceEquals", + "MemberwiseClone", + "Finalize", + ].includes(member), + `Store constants collide or are reserved: ${store.id}`, + ); + members.add(member); + names.add(store.id); + requireThat( + ["experimental", "community", "official"].includes(store.tier), + `Invalid tier: ${store.id}`, + ); + requireThat( + typeof store.displayName === "string" && store.displayName.trim(), + `Missing display name: ${store.id}`, + ); + requireThat( + Array.isArray(store.maintainers) && + store.maintainers.length > 0 && + store.maintainers.every((name) => + /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(name), + ), + `Invalid maintainers: ${store.id}`, + ); + requireThat( + typeof store.repo === "string" && /^https:\/\/[^\s]+$/.test(store.repo), + `Missing public repository: ${store.id}`, + ); + requireThat(Array.isArray(store.aliases), `Missing aliases: ${store.id}`); + for (const alias of store.aliases) { + requireThat( + typeof alias === "string" && + STORE_ID_PATTERN.test(alias) && + !names.has(alias), + `Invalid or duplicate alias: ${alias}`, + ); + names.add(alias); + } + let allBindingsPass = true; + for (const binding of storeBindings(store)) { + requireThat( + ["android", "ios"].includes(binding.platform), + `Invalid platform: ${store.id}`, + ); + requireThat( + Array.isArray(binding.capabilities) && + sameSet(binding.capabilities, [...new Set(binding.capabilities)]) && + binding.capabilities.every((item) => capabilities.includes(item)), + `Invalid capabilities: ${store.id}`, + ); + if (store.tier === "official") { + requireThat( + !store.bindings, + `Official bindings retain their native package owner: ${store.id}`, + ); + requireThat( + discriminators[store.id] && + store.repo.startsWith("https://github.com/hyodotdev/openiap/"), + `Official store must live in the monorepo: ${store.id}`, + ); + const expected = capabilities.filter( + (capability) => + CAPABILITY_MATRIX[capability].stores[discriminators[store.id]] !== + "unsupported", + ); + requireThat( + sameSet(binding.capabilities, expected), + `Official capabilities differ from matrix: ${store.id}`, + ); + requireThat( + binding.coordinates?.versionKey === + (store.id === "apple" ? "apple" : "google"), + `Official coordinate must use the version SSOT: ${store.id}`, + ); + requireThat( + binding.platform === (store.id === "apple" ? "ios" : "android"), + `Invalid official platform: ${store.id}`, + ); + if (binding.platform === "android") + requireThat( + binding.coordinates.module === + `io.github.hyochan.openiap:openiap-google${store.id === "play" ? "" : `-${store.id}`}`, + `Invalid official coordinate: ${store.id}`, + ); + } else { + const coordinate = binding.coordinates; + const valid = + binding.platform === "android" + ? typeof coordinate === "string" && + PROVIDER_COORDINATE_PATTERN.test(coordinate) && + !coordinate.startsWith("io.github.hyochan.openiap:openiap-") + : coordinate && + typeof coordinate === "object" && + /^https:\/\/[^\s]+$/.test(coordinate.package) && + /^[A-Za-z][A-Za-z0-9_-]*$/.test(coordinate.product) && + SEMVER.test(coordinate.version) && + !coordinate.package.startsWith( + "https://github.com/hyodotdev/openiap", + ); + requireThat(valid, `Invalid community coordinates: ${store.id}`); + if (binding.latestReport) { + requireThat( + /^https:\/\/[^\s]+$/.test(binding.latestReport.url), + `Report needs a public URL: ${store.id}`, + ); + requireThat( + binding.latestReport.report.storeId === store.id && + binding.latestReport.report.store === "unknown", + `Report store identity differs: ${store.id}`, + ); + const passing = validateProviderReport( + binding.latestReport.report, + binding.capabilities, + { platform: binding.platform }, + ); + allBindingsPass &&= passing; + } else allBindingsPass = false; + } + } + if (store.tier !== "official") + requireThat( + store.tier === (allBindingsPass ? "community" : "experimental"), + `Tier disagrees with conformance report: ${store.id}`, + ); + } + for (const id of Object.keys(discriminators)) + requireThat( + registry.stores.some( + (store) => store.id === id && store.tier === "official", + ), + `Missing official store: ${id}`, + ); + return registry; +}
  • - ✅ isAutoRenewing + ⚠️ autoRenewingAndroid (null when unavailable) ✅
    - Store discriminator: "apple", "google" - , or "horizon" + Fixed store discriminator: "apple",{' '} + "google", "horizon",{' '} + "amazon", or "unknown" +
    + storeId + + Required stable store id: apple, play, horizon, amazon, or the + community provider id.{' '} + + Store identity + {' '} + ·{' '} + + upgrade notes +
    isAutoRenewing Whether subscription will auto-renew + Legacy renewal hint that cannot represent unknown. Use nullable + platform renewal metadata or backend status for renewal + decisions. +
    @@ -630,7 +651,10 @@ function Purchase() { autoRenewingAndroid Whether subscription will auto-renew + Store-reported renewal status; null when unavailable. + Does not establish entitlement. +
    diff --git a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx index b9b67ebfe..42c62fef3 100644 --- a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx +++ b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx @@ -138,7 +138,25 @@ function VerifyPurchaseWithProviderResult() { The store that processed the purchase: 'apple',{' '} 'google', 'horizon', or{' '} - 'amazon'. + 'amazon', or 'unknown' for a community + Apple or Android provider. +
    + storeId + + string + + Required stable store id.{' '} + + Preserve it when routing verification results. + {' '} + + Upgrade notes. +