diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c32ac0bf..c0915163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,14 @@ jobs: bun-version: 1.1.0 - name: Install dependencies - run: bun install + run: | + # Retry bun install up to 3 times to handle transient registry errors + for i in 1 2 3; do + bun install && break + [ $i -eq 3 ] && exit 1 + echo "Attempt $i failed. Retrying..." + sleep 5 + done - name: Generate types working-directory: packages/gql @@ -56,7 +63,14 @@ jobs: bun-version: 1.1.0 - name: Install dependencies - run: bun install + run: | + # Retry bun install up to 3 times to handle transient registry errors + for i in 1 2 3; do + bun install && break + [ $i -eq 3 ] && exit 1 + echo "Attempt $i failed. Retrying..." + sleep 5 + done - name: Generate types working-directory: packages/google @@ -88,7 +102,14 @@ jobs: bun-version: 1.1.0 - name: Install dependencies - run: bun install + run: | + # Retry bun install up to 3 times to handle transient registry errors + for i in 1 2 3; do + bun install && break + [ $i -eq 3 ] && exit 1 + echo "Attempt $i failed. Retrying..." + sleep 5 + done - name: Generate types working-directory: packages/apple @@ -115,7 +136,14 @@ jobs: bun-version: 1.1.0 - name: Install dependencies - run: bun install + run: | + # Retry bun install up to 3 times to handle transient registry errors + for i in 1 2 3; do + bun install && break + [ $i -eq 3 ] && exit 1 + echo "Attempt $i failed. Retrying..." + sleep 5 + done - name: Type check working-directory: packages/docs diff --git a/.gitignore b/.gitignore index a88a1100..8587a46d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ packages/apple/Sources/openiap-versions.json Thumbs.db # IDE -.vscode/ .idea/ *.swp *.swo diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..06cc6856 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "vscjava.vscode-java-pack", + "vscjava.vscode-gradle", + "sswg.swift-lang", + "oven.bun-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..3922575f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,38 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node-terminal", + "request": "launch", + "name": "๐ŸŽ Open Apple (iOS) in Xcode", + "command": "open Example/Martie.xcodeproj", + "cwd": "${workspaceFolder}/packages/apple" + }, + { + "name": "๐Ÿค– Open Google (Android) in Android Studio", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-lc", + "./scripts/open-android-studio.sh" + ], + "cwd": "${workspaceFolder}/packages/google", + "console": "integratedTerminal" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿ“ GQL: Generate Types", + "command": "bun run generate", + "cwd": "${workspaceFolder}/packages/gql" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿ“š Docs: Dev Server", + "command": "bun run dev", + "cwd": "${workspaceFolder}/packages/docs" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..2154c63e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,44 @@ +{ + "cSpell.words": [ + "hyodotdev", + "openiap" + ], + "files.associations": { + "*.podspec": "ruby" + }, + "files.exclude": { + "**/.build": true, + "**/.gradle": true, + "**/build": true, + "**/node_modules": true, + "**/.DS_Store": true + }, + "search.exclude": { + "**/node_modules": true, + "**/.build": true, + "**/.gradle": true, + "**/build": true, + "**/dist": true + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[swift]": { + "editor.tabSize": 4, + "editor.insertSpaces": true + }, + "[kotlin]": { + "editor.tabSize": 4, + "editor.insertSpaces": true + }, + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..fe0c7496 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,248 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "๐ŸŽ Open Apple (iOS) in Xcode", + "type": "shell", + "command": "open", + "args": ["Example/Martie.xcodeproj"], + "options": { + "cwd": "${workspaceFolder}/packages/apple" + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿ“ฑ Apple: Build Swift Package", + "type": "shell", + "command": "swift", + "args": ["build"], + "options": { + "cwd": "${workspaceFolder}/packages/apple" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿงช Apple: Run Tests", + "type": "shell", + "command": "swift", + "args": ["test"], + "options": { + "cwd": "${workspaceFolder}/packages/apple" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿค– Open Google (Android) in Android Studio", + "type": "shell", + "command": "./scripts/open-android-studio.sh", + "options": { + "cwd": "${workspaceFolder}/packages/google" + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿš€ Google: Build & Run Example", + "type": "shell", + "command": "sh", + "args": ["-c", "./gradlew :Example:installDebug && adb shell am start -n dev.hyo.martie/.MainActivity"], + "options": { + "cwd": "${workspaceFolder}/packages/google" + }, + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿงฑ Google: Build Example Debug APK", + "type": "shell", + "command": "./gradlew", + "args": [":Example:assembleDebug"], + "options": { + "cwd": "${workspaceFolder}/packages/google" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿ“ฆ Google: Build Library", + "type": "shell", + "command": "./gradlew", + "args": [":openiap:build"], + "options": { + "cwd": "${workspaceFolder}/packages/google" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿงช Google: Run Unit Tests", + "type": "shell", + "command": "./gradlew", + "args": [":openiap:testDebugUnitTest", "--no-daemon"], + "options": { + "cwd": "${workspaceFolder}/packages/google" + }, + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿ“œ Google: View Logcat", + "type": "shell", + "command": "adb", + "args": [ + "logcat", + "-s", + "OpenIAP:V", + "MainActivity:V", + "System.err:V" + ], + "options": { + "cwd": "${workspaceFolder}/packages/google" + }, + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "dedicated", + "clear": true + } + }, + { + "label": "๐Ÿงน Google: Clean Build", + "type": "shell", + "command": "./gradlew", + "args": ["clean"], + "options": { + "cwd": "${workspaceFolder}/packages/google" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿ“ GQL: Generate All Types", + "type": "shell", + "command": "bun", + "args": ["run", "generate"], + "options": { + "cwd": "${workspaceFolder}/packages/gql" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿ“š Docs: Dev Server", + "type": "shell", + "command": "bun", + "args": ["run", "dev"], + "options": { + "cwd": "${workspaceFolder}/packages/docs" + }, + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿ”จ Docs: Build", + "type": "shell", + "command": "bun", + "args": ["run", "build"], + "options": { + "cwd": "${workspaceFolder}/packages/docs" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "๐Ÿ”„ Sync Versions", + "type": "shell", + "command": "bun", + "args": ["run", "version:sync"], + "options": { + "cwd": "${workspaceFolder}" + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + } + ] +} diff --git a/gql/.vscode/settings.json b/gql/.vscode/settings.json new file mode 100644 index 00000000..c1659144 --- /dev/null +++ b/gql/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "cSpell.words": [ + "apollographql", + "codegen", + "gradlew", + "openiap", + "preorder", + "pubspec", + "skus" + ] +} \ No newline at end of file diff --git a/packages/apple/.vscode/launch.json b/packages/apple/.vscode/launch.json new file mode 100644 index 00000000..15307128 --- /dev/null +++ b/packages/apple/.vscode/launch.json @@ -0,0 +1,54 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node-terminal", + "request": "launch", + "name": "๐ŸŽ Open Example in Xcode", + "command": "open Martie.xcodeproj", + "cwd": "${workspaceFolder}/Example" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿ“ฑ Build & Run Example (iOS Simulator)", + "command": "xcodebuild -project Martie.xcodeproj -scheme OpenIapExample -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' build", + "cwd": "${workspaceFolder}/Example" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿ“ฆ Swift Package Build", + "command": "swift build", + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿงช Swift Package Test", + "command": "swift test", + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "โ˜• CocoaPods Build", + "command": "pod install", + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿ” CocoaPods Lint", + "command": "pod lib lint --allow-warnings", + "cwd": "${workspaceFolder}" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "๐Ÿš€ Swift Test (Watch Mode)", + "command": "swift test --parallel", + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/packages/apple/.vscode/settings.json b/packages/apple/.vscode/settings.json new file mode 100644 index 00000000..4886745b --- /dev/null +++ b/packages/apple/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "cSpell.words": [ + "hyodotdev", + "inapp", + "netrc", + "openiap", + "preorder", + "skus", + "swiftpm", + "tvos", + "watchos", + "xcuserdata", + "xcworkspace", + "xcworkspacedata" + ] +} \ No newline at end of file diff --git a/packages/docs/src/pages/docs/horizon-setup.tsx b/packages/docs/src/pages/docs/horizon-setup.tsx index 389485ff..f4494f78 100644 --- a/packages/docs/src/pages/docs/horizon-setup.tsx +++ b/packages/docs/src/pages/docs/horizon-setup.tsx @@ -58,8 +58,9 @@ function HorizonSetup() { }} > โ„น๏ธ Note: OpenIAP uses the same Android SDK for both - Google Play and Horizon OS. The SDK automatically detects the platform - and uses the appropriate billing implementation. + Google Play and Horizon OS. The build flavor determines which billing + implementation is compiled into your APK. If no flavor is specified, + it defaults to Play (Google Play Billing). @@ -191,24 +192,25 @@ function HorizonSetup() { -

- Option 1: Automatic Platform Detection (Recommended) - +

+ Option 1: Default Constructor (Recommended) + #

- The simplest approach - OpenIAP automatically detects whether you're on - Google Play or Horizon OS: + The simplest approach - the build flavor you select determines which billing SDK is compiled into your APK:

-{`// Kotlin -val store = OpenIapStore(context, store = "auto") +{`// Kotlin - Default constructor +val store = OpenIapStore(context) + +// Build with horizonDebug/horizonRelease: +// - APK includes Horizon Billing SDK +// - Reads OCULUS_APP_ID from AndroidManifest -// The SDK will automatically: -// - Use Horizon Billing on Quest devices -// - Use Google Play Billing on phones/tablets -// - Read OCULUS_APP_ID from AndroidManifest`} +// Build with playDebug/playRelease (default): +// - APK includes Google Play Billing SDK`}

@@ -236,8 +238,8 @@ val store = OpenIapStore(

-{`// Initialize with auto-detection -val store = OpenIapStore(context, store = "auto") +{`// Initialize store (uses build flavor) +val store = OpenIapStore(context) // Connect to billing lifecycleScope.launch { @@ -383,14 +385,6 @@ android { buildConfigField("String", "HORIZON_APP_ID", "\\"$horizonAppId\\"") manifestPlaceholders["OCULUS_APP_ID"] = horizonAppId } - - create("auto") { - dimension = "platform" - isDefault = true - // Supports both platforms (auto-detection) - buildConfigField("String", "HORIZON_APP_ID", "\\"$horizonAppId\\"") - manifestPlaceholders["OCULUS_APP_ID"] = horizonAppId - } } }`} @@ -406,15 +400,16 @@ android {

+

+ Build separate APKs for each platform: horizonDebug/horizonRelease for Quest devices, + playDebug/playRelease for Android phones/tablets. +

๐Ÿ’ก Tip: To change build variant in Android Studio:
  1. Open "Build Variants" panel (View โ†’ Tool Windows โ†’ Build Variants)
  2. -
  3. Select your desired variant (e.g., "autoDebug", "horizonDebug", "playDebug")
  4. +
  5. Select your desired variant (e.g., "horizonDebug" or "playDebug")
  6. Click the "Run" button to build and install
@@ -441,17 +436,15 @@ android {

Alternatively, build from command line:

-{`# Build for Horizon OS only +{`# Build for Horizon OS (Meta Quest devices) ./gradlew assembleHorizonDebug -# Build for Google Play only +# Build for Google Play (Android phones/tablets) ./gradlew assemblePlayDebug -# Build auto-detecting version (recommended) -./gradlew assembleAutoDebug - # Install directly to connected device -./gradlew installAutoDebug`} +./gradlew installHorizonDebug +./gradlew installPlayDebug`} diff --git a/packages/google/Example/build.gradle.kts b/packages/google/Example/build.gradle.kts index 447a77f6..8b961afe 100644 --- a/packages/google/Example/build.gradle.kts +++ b/packages/google/Example/build.gradle.kts @@ -40,23 +40,11 @@ android { flavorDimensions += "platform" productFlavors { - // Auto flavor (default) - includes both libraries, detects platform at runtime - create("auto") { - dimension = "platform" - buildConfigField("String", "OPENIAP_STORE", "\"auto\"") - isDefault = true - - // Dynamically inject OCULUS_APP_ID into AndroidManifest (needed for Horizon) - val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID") - ?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?) - ?: "" - manifestPlaceholders["OCULUS_APP_ID"] = appId - } - - // Play flavor - Google Play Billing only + // Play flavor - Google Play Billing (default) create("play") { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"play\"") + isDefault = true } // Horizon flavor - Meta Horizon Billing only diff --git a/packages/google/Example/src/horizon/AndroidManifest.xml b/packages/google/Example/src/horizon/AndroidManifest.xml deleted file mode 100644 index daba2856..00000000 --- a/packages/google/Example/src/horizon/AndroidManifest.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index 6520de9b..a517777b 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -35,16 +35,11 @@ android { flavorDimensions += "platform" productFlavors { - // Auto flavor (default) - includes both libraries, detects platform at runtime - create("auto") { - dimension = "platform" - buildConfigField("String", "OPENIAP_STORE", "\"auto\"") - isDefault = true - } - // Play flavor - Google Play Billing only + // Play flavor - Google Play Billing only (default) create("play") { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"play\"") + isDefault = true } // Horizon flavor - Meta Horizon Billing only create("horizon") { @@ -68,38 +63,26 @@ android { buildConfig = true } - // Configure source sets for flavors - // Auto flavor includes horizon implementation only - sourceSets { - getByName("auto") { - java.srcDir("src/horizon/java") - } - } + // Source sets are automatically configured per flavor + // play/ and horizon/ directories are used by their respective flavors } dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - // Billing libraries strategy: - // - All flavors need Play Billing API for compilation (main/ source uses it) - // - Auto & Horizon use Horizon Compatibility Library at runtime - // - Play uses Google Play Billing at runtime + // Billing libraries per flavor (completely independent): + // - Play flavor uses Google Play Billing (main/ source uses it) + // - Horizon flavor uses Meta Horizon Billing Compatibility Library - // Compile-time dependency for main/ source set - compileOnly("com.android.billingclient:billing-ktx:8.0.0") - - // Runtime dependencies per flavor: - // Play flavor: Google Play Billing only + // Play flavor: Google Play Billing API (compile + runtime) + add("playCompileOnly", "com.android.billingclient:billing-ktx:8.0.0") add("playApi", "com.android.billingclient:billing-ktx:8.0.0") - // Auto flavor: BOTH libraries for true cross-platform support - // - Google Play Billing for Android phones - // - Horizon Compatibility Library for Horizon OS (includes duplicate classes, but runtime selects correct one) - add("autoApi", "com.android.billingclient:billing-ktx:8.0.0") - add("autoApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") - - // Horizon flavor: Horizon Compatibility Library only + // Horizon flavor: Meta Horizon Platform SDK and Billing Compatibility Library (compile + runtime) + add("horizonCompileOnly", "com.meta.horizon.platform.ovr:android-platform-sdk:72") + add("horizonApi", "com.meta.horizon.platform.ovr:android-platform-sdk:72") + add("horizonCompileOnly", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") add("horizonApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") // Kotlin Coroutines @@ -130,9 +113,9 @@ mavenPublishing { val groupId = project.findProperty("OPENIAP_GROUP_ID")?.toString() ?: "io.github.hyochan.openiap" coordinates(groupId, "openiap-google", openIapVersion) - // Publish the Auto flavor (supports both Play and Horizon) + // Publish the Play flavor (Google Play Billing) configure(com.vanniktech.maven.publish.AndroidSingleVariantLibrary( - variant = "autoRelease", + variant = "playRelease", sourcesJar = true, publishJavadocJar = true )) diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapErrorExtensions.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapErrorExtensions.kt new file mode 100644 index 00000000..3b47f52f --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapErrorExtensions.kt @@ -0,0 +1,24 @@ +package dev.hyo.openiap + +import com.meta.horizon.billingclient.api.BillingClient + +/** + * Extension function for converting Horizon Billing response codes to OpenIapError + */ +@Suppress("DEPRECATION") +fun OpenIapError.Companion.fromBillingResponseCode(responseCode: Int, debugMessage: String? = null): OpenIapError { + return when (responseCode) { + BillingClient.BillingResponseCode.USER_CANCELED -> OpenIapError.UserCancelled + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> OpenIapError.ServiceUnavailable + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> OpenIapError.BillingUnavailable + BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> OpenIapError.ItemUnavailable + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> OpenIapError.DeveloperError + BillingClient.BillingResponseCode.ERROR -> OpenIapError.BillingError + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> OpenIapError.ItemAlreadyOwned + BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> OpenIapError.ItemNotOwned + BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> OpenIapError.ServiceDisconnected + BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> OpenIapError.FeatureNotSupported + BillingClient.BillingResponseCode.SERVICE_TIMEOUT -> OpenIapError.ServiceTimeout + else -> OpenIapError.UnknownError + } +} diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt similarity index 94% rename from packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt rename to packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index 921189ce..34f6d261 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -1,4 +1,4 @@ -package dev.hyo.openiap.horizon +package dev.hyo.openiap import android.app.Activity import android.content.Context @@ -18,58 +18,20 @@ import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase import com.meta.horizon.billingclient.api.PurchasesUpdatedListener import com.meta.horizon.billingclient.api.QueryProductDetailsParams import com.meta.horizon.billingclient.api.QueryPurchasesParams -import dev.hyo.openiap.ActiveSubscription -import dev.hyo.openiap.FetchProductsResult -import dev.hyo.openiap.FetchProductsResultProducts -import dev.hyo.openiap.FetchProductsResultSubscriptions -import dev.hyo.openiap.IapPlatform -import dev.hyo.openiap.MutationAcknowledgePurchaseAndroidHandler -import dev.hyo.openiap.MutationConsumePurchaseAndroidHandler -import dev.hyo.openiap.MutationDeepLinkToSubscriptionsHandler -import dev.hyo.openiap.MutationEndConnectionHandler -import dev.hyo.openiap.MutationFinishTransactionHandler -import dev.hyo.openiap.MutationHandlers -import dev.hyo.openiap.MutationInitConnectionHandler -import dev.hyo.openiap.MutationRequestPurchaseHandler -import dev.hyo.openiap.MutationRestorePurchasesHandler -import dev.hyo.openiap.MutationValidateReceiptHandler -import dev.hyo.openiap.OpenIapError -import dev.hyo.openiap.OpenIapLog -import dev.hyo.openiap.OpenIapProtocol -import dev.hyo.openiap.Product -import dev.hyo.openiap.ProductAndroid -import dev.hyo.openiap.ProductQueryType -import dev.hyo.openiap.ProductSubscriptionAndroid -import dev.hyo.openiap.ProductType -import dev.hyo.openiap.Purchase -import dev.hyo.openiap.PurchaseAndroid -import dev.hyo.openiap.PurchaseInput -import dev.hyo.openiap.QueryFetchProductsHandler -import dev.hyo.openiap.QueryGetActiveSubscriptionsHandler -import dev.hyo.openiap.QueryGetAvailablePurchasesHandler -import dev.hyo.openiap.QueryHandlers -import dev.hyo.openiap.QueryHasActiveSubscriptionsHandler -import dev.hyo.openiap.ReceiptValidationProps -import dev.hyo.openiap.RequestPurchaseResultPurchase -import dev.hyo.openiap.RequestPurchaseResultPurchases -import dev.hyo.openiap.RequestPurchaseProps -import dev.hyo.openiap.SubscriptionHandlers -import dev.hyo.openiap.SubscriptionPurchaseErrorHandler -import dev.hyo.openiap.SubscriptionPurchaseUpdatedHandler import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener import dev.hyo.openiap.helpers.onPurchaseError import dev.hyo.openiap.helpers.onPurchaseUpdated import dev.hyo.openiap.helpers.toAndroidPurchaseArgs -import dev.hyo.openiap.horizon.helpers.restorePurchasesHorizon -import dev.hyo.openiap.horizon.helpers.queryPurchasesHorizon -import dev.hyo.openiap.horizon.helpers.HorizonProductManager -import dev.hyo.openiap.horizon.helpers.queryProductDetailsHorizon -import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toActiveSubscription -import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toInAppProduct -import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toPurchase -import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toSubscriptionProduct +import dev.hyo.openiap.helpers.restorePurchasesHorizon +import dev.hyo.openiap.helpers.queryPurchasesHorizon +import dev.hyo.openiap.helpers.ProductManager +import dev.hyo.openiap.helpers.queryProductDetailsHorizon +import dev.hyo.openiap.utils.HorizonBillingConverters.toActiveSubscription +import dev.hyo.openiap.utils.HorizonBillingConverters.toInAppProduct +import dev.hyo.openiap.utils.HorizonBillingConverters.toPurchase +import dev.hyo.openiap.utils.HorizonBillingConverters.toSubscriptionProduct import dev.hyo.openiap.utils.toProduct import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -82,15 +44,25 @@ import java.lang.ref.WeakReference import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -private const val TAG = "OpenIapHorizonModule" - -class OpenIapHorizonModule( +private const val TAG = "OpenIapModule" + +/** + * OpenIapModule for Meta Horizon Billing + * + * @param context Android context + * @param alternativeBillingMode Alternative billing mode (default: NONE) + * @param userChoiceBillingListener Listener for user choice billing selection (optional) + * + * Note: Oculus App ID is read from AndroidManifest.xml meta-data with key "com.oculus.vr.APP_ID" + */ +class OpenIapModule( private val context: Context, - private val appId: String? = null + private var alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE, + private var userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null ) : OpenIapProtocol, PurchasesUpdatedListener { companion object { - // CRITICAL FIX: Shared purchase cache across all OpenIapHorizonModule instances + // CRITICAL FIX: Shared purchase cache across all OpenIapModule instances // This ensures purchases are available even when connection is closed and reopened // Using ConcurrentHashMap for thread-safety across coroutines private val sharedPurchaseCache = java.util.concurrent.ConcurrentHashMap() @@ -99,10 +71,26 @@ class OpenIapHorizonModule( private const val PURCHASE_QUERY_DELAY_MS = 500L } + // Read Oculus App ID from AndroidManifest.xml + private val appId: String? by lazy { + try { + val appInfo = context.packageManager.getApplicationInfo( + context.packageName, + android.content.pm.PackageManager.GET_META_DATA + ) + val id = appInfo.metaData?.getString("com.oculus.vr.APP_ID") + android.util.Log.i(TAG, "Read Oculus App ID from manifest: $id") + id + } catch (e: Exception) { + android.util.Log.w(TAG, "Failed to read com.oculus.vr.APP_ID from AndroidManifest.xml: ${e.message}") + null + } + } + private var billingClient: BillingClient? = null private var currentActivityRef: WeakReference? = null private var currentPurchaseCallback: ((Result>) -> Unit)? = null - private val productManager = HorizonProductManager() + private val productManager = ProductManager() private val fallbackActivity: Activity? = if (context is Activity) context else null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -110,7 +98,7 @@ class OpenIapHorizonModule( private val purchaseErrorListeners = mutableSetOf() init { - android.util.Log.i(TAG, "=== OpenIapHorizonModule INIT (Modified version with fix) ===") + android.util.Log.i(TAG, "=== OpenIapModule INIT (Horizon flavor) ===") buildBillingClient() } @@ -640,7 +628,7 @@ class OpenIapHorizonModule( } } - override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported } + override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.FeatureNotSupported } private val purchaseError: SubscriptionPurchaseErrorHandler = { onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) @@ -675,7 +663,7 @@ class OpenIapHorizonModule( purchaseUpdated = purchaseUpdated ) - private suspend fun getStorefront(): String = withContext(Dispatchers.IO) { + suspend fun getStorefront(): String = withContext(Dispatchers.IO) { val client = billingClient ?: return@withContext "" suspendCancellableCoroutine { continuation -> runCatching { @@ -833,9 +821,14 @@ class OpenIapHorizonModule( .newBuilder(context) .setListener(this) .enablePendingPurchases(pendingPurchasesParams) - if (!appId.isNullOrEmpty()) { - builder.setAppId(appId) + + // Set app ID if available from manifest + appId?.let { id -> + if (id.isNotEmpty()) { + builder.setAppId(id) + } } + billingClient = builder.build() } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt similarity index 100% rename from packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt rename to packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/Helpers.kt similarity index 95% rename from packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt rename to packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/Helpers.kt index cbc7f84b..2df94910 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/Helpers.kt @@ -1,4 +1,4 @@ -package dev.hyo.openiap.horizon.helpers +package dev.hyo.openiap.helpers import com.meta.horizon.billingclient.api.BillingClient import com.meta.horizon.billingclient.api.QueryPurchasesParams @@ -7,11 +7,11 @@ import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapLog import dev.hyo.openiap.Purchase -import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toPurchase +import dev.hyo.openiap.utils.HorizonBillingConverters.toPurchase import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume -private const val TAG = "HorizonHelpers" +private const val TAG = "Helpers" /** * Query and restore all purchases (both INAPP and SUBS) for Horizon @@ -97,7 +97,7 @@ internal suspend fun queryPurchasesHorizon( */ internal suspend fun queryProductDetailsHorizon( client: BillingClient?, - productManager: HorizonProductManager, + productManager: ProductManager, skus: List, productType: String ): List { diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/ProductManager.kt similarity index 97% rename from packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt rename to packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/ProductManager.kt index a9c9650c..6f91999d 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/ProductManager.kt @@ -1,4 +1,4 @@ -package dev.hyo.openiap.horizon.helpers +package dev.hyo.openiap.helpers import com.meta.horizon.billingclient.api.BillingClient import com.meta.horizon.billingclient.api.QueryProductDetailsParams @@ -8,12 +8,12 @@ import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.resume -private const val TAG = "HorizonProductManager" +private const val TAG = "ProductManager" /** * Manages ProductDetails caching and queries for Horizon. */ -internal class HorizonProductManager { +internal class ProductManager { private data class CacheKey(val productId: String, val productType: String) private val cache = ConcurrentHashMap() diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt new file mode 100644 index 00000000..93b4cdd5 --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt @@ -0,0 +1,118 @@ +package dev.hyo.openiap.helpers + +import dev.hyo.openiap.AndroidSubscriptionOfferInput +import dev.hyo.openiap.ErrorCode +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.ProductQueryType +import dev.hyo.openiap.Purchase +import dev.hyo.openiap.PurchaseError +import dev.hyo.openiap.RequestPurchaseProps +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Suspend function to wait for purchase update (Horizon) + */ +internal suspend fun onPurchaseUpdated( + addListener: (OpenIapPurchaseUpdateListener) -> Unit, + removeListener: (OpenIapPurchaseUpdateListener) -> Unit +): Purchase = suspendCancellableCoroutine { continuation -> + val listener = object : OpenIapPurchaseUpdateListener { + override fun onPurchaseUpdated(purchase: Purchase) { + removeListener(this) + if (continuation.isActive) continuation.resume(purchase) + } + } + addListener(listener) + continuation.invokeOnCancellation { removeListener(listener) } +} + +/** + * Suspend function to wait for purchase error (Horizon) + */ +internal suspend fun onPurchaseError( + addListener: (OpenIapPurchaseErrorListener) -> Unit, + removeListener: (OpenIapPurchaseErrorListener) -> Unit +): PurchaseError = suspendCancellableCoroutine { continuation -> + val listener = object : OpenIapPurchaseErrorListener { + override fun onPurchaseError(error: OpenIapError) { + removeListener(this) + if (continuation.isActive) continuation.resume(error.toPurchaseError()) + } + } + addListener(listener) + continuation.invokeOnCancellation { removeListener(listener) } +} + +/** + * Data class for Android purchase arguments (Horizon) + */ +internal data class AndroidPurchaseArgs( + val skus: List, + val isOfferPersonalized: Boolean?, + val obfuscatedAccountId: String?, + val obfuscatedProfileId: String?, + val purchaseTokenAndroid: String?, + val replacementModeAndroid: Int?, + val subscriptionOffers: List?, + val type: ProductQueryType, + val useAlternativeBilling: Boolean? +) + +/** + * Extension function to convert RequestPurchaseProps to AndroidPurchaseArgs (Horizon) + */ +internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { + return when (val payload = request) { + is RequestPurchaseProps.Request.Purchase -> { + val android = payload.value.android + ?: throw IllegalArgumentException("Android purchase parameters are required") + AndroidPurchaseArgs( + skus = android.skus, + isOfferPersonalized = android.isOfferPersonalized, + obfuscatedAccountId = android.obfuscatedAccountIdAndroid, + obfuscatedProfileId = android.obfuscatedProfileIdAndroid, + purchaseTokenAndroid = null, + replacementModeAndroid = null, + subscriptionOffers = null, + type = type, + useAlternativeBilling = useAlternativeBilling + ) + } + is RequestPurchaseProps.Request.Subscription -> { + val android = payload.value.android + ?: throw IllegalArgumentException("Android subscription parameters are required") + + // For subscription upgrades/downgrades: + // - purchaseTokenAndroid: Identifies which existing subscription to upgrade/downgrade + // - obfuscatedProfileId: Optional user identifier for fraud prevention and attribution + // Both can be provided together - they serve different purposes and are not mutually exclusive + AndroidPurchaseArgs( + skus = android.skus, + isOfferPersonalized = android.isOfferPersonalized, + obfuscatedAccountId = android.obfuscatedAccountIdAndroid, + obfuscatedProfileId = android.obfuscatedProfileIdAndroid, + purchaseTokenAndroid = android.purchaseTokenAndroid, + replacementModeAndroid = android.replacementModeAndroid, + subscriptionOffers = android.subscriptionOffers, + type = type, + useAlternativeBilling = useAlternativeBilling + ) + } + } +} + +/** + * Extension function to convert OpenIapError to PurchaseError (Horizon) + */ +internal fun OpenIapError.toPurchaseError(): PurchaseError { + val code = runCatching { ErrorCode.fromJson(this.code) }.getOrElse { ErrorCode.Unknown } + val productId = when (this) { + is OpenIapError.ProductNotFound -> productId + is OpenIapError.SkuNotFound -> sku + else -> null + } + return PurchaseError(code = code, message = message, productId = productId) +} diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt new file mode 100644 index 00000000..13cd2824 --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt @@ -0,0 +1,28 @@ +package dev.hyo.openiap.store + +import android.content.Context +import dev.hyo.openiap.AlternativeBillingMode +import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.listener.UserChoiceBillingListener + +/** + * Horizon-specific extensions for OpenIapStore + * These constructors are only available in the Horizon flavor + * + * Note: Oculus App ID is automatically read from AndroidManifest.xml meta-data + * with key "com.oculus.vr.APP_ID". Make sure it's properly configured via expo-iap plugin. + */ + +/** + * Convenience constructor that creates OpenIapModule (Horizon flavor) with alternative billing support + * + * @param context Android context + * @param alternativeBillingMode Alternative billing mode (default: NONE) + * @param userChoiceBillingListener Listener for user choice billing selection (optional) + */ +fun OpenIapStore( + context: Context, + alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE, + userChoiceBillingListener: UserChoiceBillingListener? = null +): OpenIapStore = OpenIapStore(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener) as OpenIapProtocol) diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt similarity index 99% rename from packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt rename to packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt index f423c43a..be9cfb94 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -1,4 +1,4 @@ -package dev.hyo.openiap.horizon.utils +package dev.hyo.openiap.utils import com.meta.horizon.billingclient.api.ProductDetails as HorizonProductDetails import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/AlternativeBillingMode.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/AlternativeBillingMode.kt new file mode 100644 index 00000000..476be882 --- /dev/null +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/AlternativeBillingMode.kt @@ -0,0 +1,14 @@ +package dev.hyo.openiap + +/** + * Alternative billing mode + * Supported by both Google Play Billing and Meta Horizon Billing + */ +enum class AlternativeBillingMode { + /** Standard billing (default) - Google Play or Meta Horizon */ + NONE, + /** Alternative billing with user choice (user selects between platform billing or alternative) */ + USER_CHOICE, + /** Alternative billing only (no platform billing option) */ + ALTERNATIVE_ONLY +} 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 36462370..65b3a7e5 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,7 +1,5 @@ package dev.hyo.openiap -import com.android.billingclient.api.BillingClient - /** * OpenIAP specific exceptions */ @@ -105,14 +103,6 @@ sealed class OpenIapError : Exception() { const val MESSAGE = "Unknown error" } - object NotSupported : OpenIapError() { - val CODE = ErrorCode.FeatureNotSupported.rawValue - override val code: String = CODE - override val message: String = MESSAGE - - const val MESSAGE = "Operation not supported" - } - object NotPrepared : OpenIapError() { const val CODE = "not-prepared" const val MESSAGE = "Billing client not ready" @@ -196,7 +186,7 @@ sealed class OpenIapError : Exception() { override val code: String = CODE override val message: String = MESSAGE - const val MESSAGE = "Google Play service is unavailable" + const val MESSAGE = "Billing service is unavailable" } object BillingUnavailable : OpenIapError() { @@ -240,11 +230,11 @@ sealed class OpenIapError : Exception() { } object ServiceTimeout : OpenIapError() { - val CODE = ErrorCode.ServiceDisconnected.rawValue + const val CODE = "service-timeout" override val code: String = CODE override val message: String = MESSAGE - const val MESSAGE = "The request has reached the maximum timeout before Google Play responds" + const val MESSAGE = "The request has reached the maximum timeout before billing service responds" } class AlternativeBillingUnavailable(val details: String) : OpenIapError() { @@ -260,32 +250,34 @@ sealed class OpenIapError : Exception() { companion object { private val defaultMessages: Map by lazy { mapOf( - ErrorCode.SkuNotFound.rawValue to ProductNotFound.MESSAGE, - ErrorCode.PurchaseError.rawValue to PurchaseFailed.MESSAGE, - ErrorCode.UserCancelled.rawValue to PurchaseCancelled.MESSAGE, - ErrorCode.DeferredPayment.rawValue to PurchaseDeferred.MESSAGE, - ErrorCode.NetworkError.rawValue to NetworkError.MESSAGE, - ErrorCode.Unknown.rawValue to UnknownError.MESSAGE, - ErrorCode.NotPrepared.rawValue to NotPrepared.MESSAGE, - ErrorCode.InitConnection.rawValue to InitConnection.MESSAGE, - ErrorCode.QueryProduct.rawValue to QueryProduct.MESSAGE, - ErrorCode.EmptySkuList.rawValue to EmptySkuList.MESSAGE, - ErrorCode.SkuNotFound.rawValue to SkuNotFound.MESSAGE, - ErrorCode.SkuOfferMismatch.rawValue to SkuOfferMismatch.MESSAGE, - ErrorCode.UserCancelled.rawValue to UserCancelled.MESSAGE, - ErrorCode.AlreadyOwned.rawValue to ItemAlreadyOwned.MESSAGE, - ErrorCode.ItemNotOwned.rawValue to ItemNotOwned.MESSAGE, - ErrorCode.BillingUnavailable.rawValue to BillingUnavailable.MESSAGE, - ErrorCode.ItemUnavailable.rawValue to ItemUnavailable.MESSAGE, - ErrorCode.DeveloperError.rawValue to DeveloperError.MESSAGE, - ErrorCode.FeatureNotSupported.rawValue to FeatureNotSupported.MESSAGE, - ErrorCode.ServiceDisconnected.rawValue to ServiceDisconnected.MESSAGE, - ErrorCode.UserError.rawValue to PaymentNotAllowed.MESSAGE, - ErrorCode.ServiceError.rawValue to BillingError.MESSAGE, - ErrorCode.ReceiptFailed.rawValue to InvalidReceipt.MESSAGE, - ErrorCode.TransactionValidationFailed.rawValue to VerificationFailed.MESSAGE, - ErrorCode.SyncError.rawValue to RestoreFailed.MESSAGE, - ErrorCode.ActivityUnavailable.rawValue to MissingCurrentActivity.MESSAGE + ProductNotFound.CODE to ProductNotFound.MESSAGE, + PurchaseFailed.CODE to PurchaseFailed.MESSAGE, + PurchaseCancelled.CODE to PurchaseCancelled.MESSAGE, + PurchaseDeferred.CODE to PurchaseDeferred.MESSAGE, + NetworkError.CODE to NetworkError.MESSAGE, + UnknownError.CODE to UnknownError.MESSAGE, + NotPrepared.CODE to NotPrepared.MESSAGE, + InitConnection.CODE to InitConnection.MESSAGE, + QueryProduct.CODE to QueryProduct.MESSAGE, + EmptySkuList.CODE to EmptySkuList.MESSAGE, + SkuNotFound.CODE to SkuNotFound.MESSAGE, + SkuOfferMismatch.CODE to SkuOfferMismatch.MESSAGE, + UserCancelled.CODE to UserCancelled.MESSAGE, + ItemAlreadyOwned.CODE to ItemAlreadyOwned.MESSAGE, + ItemNotOwned.CODE to ItemNotOwned.MESSAGE, + ServiceUnavailable.CODE to ServiceUnavailable.MESSAGE, + BillingUnavailable.CODE to BillingUnavailable.MESSAGE, + ItemUnavailable.CODE to ItemUnavailable.MESSAGE, + DeveloperError.CODE to DeveloperError.MESSAGE, + FeatureNotSupported.CODE to FeatureNotSupported.MESSAGE, + ServiceDisconnected.CODE to ServiceDisconnected.MESSAGE, + ServiceTimeout.CODE to ServiceTimeout.MESSAGE, + PaymentNotAllowed.CODE to PaymentNotAllowed.MESSAGE, + BillingError.CODE to BillingError.MESSAGE, + InvalidReceipt.CODE to InvalidReceipt.MESSAGE, + VerificationFailed.CODE to VerificationFailed.MESSAGE, + RestoreFailed.CODE to RestoreFailed.MESSAGE, + MissingCurrentActivity.CODE to MissingCurrentActivity.MESSAGE ) } @@ -294,24 +286,6 @@ sealed class OpenIapError : Exception() { fun defaultMessage(code: String): String = defaultMessages[code] ?: "Unknown error occurred" - @Suppress("DEPRECATION") - fun fromBillingResponseCode(responseCode: Int, debugMessage: String? = null): OpenIapError { - return when (responseCode) { - BillingClient.BillingResponseCode.USER_CANCELED -> UserCancelled - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> ServiceUnavailable - BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> BillingUnavailable - BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> ItemUnavailable - BillingClient.BillingResponseCode.DEVELOPER_ERROR -> DeveloperError - BillingClient.BillingResponseCode.ERROR -> BillingError - BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> ItemAlreadyOwned - BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> ItemNotOwned - BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> ServiceDisconnected - BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> FeatureNotSupported - BillingClient.BillingResponseCode.SERVICE_TIMEOUT -> ServiceTimeout - else -> UnknownError - } - } - fun getAllErrorCodes(): Map = defaultMessages } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/MissingCurrentActivityException.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/MissingCurrentActivityException.kt deleted file mode 100644 index 59edc9d0..00000000 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/MissingCurrentActivityException.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.hyo.openiap.helpers - -/** - * Exception thrown when current activity is not available - */ -class MissingCurrentActivityException : Exception("Current activity is not available") 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 96c2e1f1..a28c77a5 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -35,13 +35,15 @@ import android.app.Activity import android.content.Context import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapLog -import dev.hyo.openiap.OpenIapModule +// OpenIapModule is loaded via reflection to support both Play and Horizon flavors import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.utils.toProduct import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -61,30 +63,10 @@ class OpenIapStore(private val module: OpenIapProtocol) { constructor(context: Context, store: String?) : this(buildModule(context, store, null)) constructor(context: Context, store: String?, appId: String?) : this(buildModule(context, store, appId)) - /** - * Convenience constructor that creates OpenIapModule with alternative billing support - * - * @param context Android context - * @param alternativeBillingMode Alternative billing mode (default: NONE) - * @param userChoiceBillingListener Listener for user choice billing selection (optional) - */ - constructor( - context: Context, - alternativeBillingMode: dev.hyo.openiap.AlternativeBillingMode = dev.hyo.openiap.AlternativeBillingMode.NONE, - userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null - ) : this(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener) as OpenIapProtocol) + // Play-specific alternative billing constructors moved to play/store/OpenIapStoreExtensions.kt - /** - * Convenience constructor for backward compatibility - * - * @param context Android context - * @param enableAlternativeBilling Enable alternative billing mode (uses ALTERNATIVE_ONLY mode) - */ - @Deprecated("Use constructor with AlternativeBillingMode instead", ReplaceWith("OpenIapStore(context, if (enableAlternativeBilling) AlternativeBillingMode.ALTERNATIVE_ONLY else AlternativeBillingMode.NONE)")) - constructor( - context: Context, - enableAlternativeBilling: Boolean - ) : this(OpenIapModule(context, enableAlternativeBilling) as OpenIapProtocol) + // Coroutine scope for background operations + private val storeScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) // Public state private val _isConnected = MutableStateFlow(false) @@ -126,7 +108,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { // CRITICAL FIX: Refresh available purchases to update UI // This ensures the purchase list reflects the new purchase immediately - kotlinx.coroutines.GlobalScope.launch { + storeScope.launch { try { android.util.Log.i("OpenIapStore", "Purchase update received, refreshing available purchases") @@ -214,6 +196,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { module.removePurchaseErrorListener(purchaseErrorListener) processedPurchaseTokens.clear() pendingRequestProductId = null + storeScope.cancel() } // ------------------------------------------------------------------------- @@ -343,7 +326,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { try { module.mutationHandlers.requestPurchase?.invoke(props) - ?: throw OpenIapError.NotSupported + ?: throw OpenIapError.FeatureNotSupported } finally { if (skuForStatus != null) removePurchasing(skuForStatus) } @@ -560,70 +543,76 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI val selected = (store ?: defaultStore).lowercase() - // For Horizon flavors, try to get app ID from manifest if not provided - val resolvedAppId = if ((selected == "horizon" || selected == "meta" || selected == "quest" || selected == "auto") && appId.isNullOrEmpty()) { - try { - val applicationInfo = context.packageManager.getApplicationInfo( - context.packageName, - android.content.pm.PackageManager.GET_META_DATA - ) - val metaAppId = applicationInfo.metaData?.getString("com.meta.horizon.platform.ovr.OCULUS_APP_ID") - android.util.Log.i("OpenIapStore", "Read OCULUS_APP_ID from manifest: $metaAppId") - metaAppId ?: "" - } catch (e: Throwable) { - android.util.Log.w("OpenIapStore", "Failed to read OCULUS_APP_ID from manifest: ${e.message}") - "" - } - } else { - appId ?: "" - } - - android.util.Log.i("OpenIapStore", "buildModule: selected=$selected, appId=$resolvedAppId, defaultStore=$defaultStore") - OpenIapLog.d("buildModule: selected=$selected, appId=$resolvedAppId, defaultStore=$defaultStore", "OpenIapStore") + android.util.Log.i("OpenIapStore", "buildModule: selected=$selected, defaultStore=$defaultStore") + OpenIapLog.d("buildModule: selected=$selected, defaultStore=$defaultStore", "OpenIapStore") return when (selected) { "horizon", "meta", "quest" -> { - try { - OpenIapLog.d("Loading OpenIapHorizonModule with appId=$resolvedAppId", "OpenIapStore") - val clazz = Class.forName("dev.hyo.openiap.horizon.OpenIapHorizonModule") - val constructor = clazz.getConstructor(Context::class.java, String::class.java) - val instance = constructor.newInstance(context, resolvedAppId) as OpenIapProtocol - OpenIapLog.d("Successfully loaded OpenIapHorizonModule", "OpenIapStore") - instance - } catch (e: Throwable) { - // Fallback to Play Store implementation - OpenIapLog.e("Failed to load OpenIapHorizonModule, falling back to Play", e, "OpenIapStore") - OpenIapModule(context) as OpenIapProtocol - } - } - "auto" -> { - // Auto-detect environment - if (isHorizonEnvironment(context)) { - try { - val clazz = Class.forName("dev.hyo.openiap.horizon.OpenIapHorizonModule") - val constructor = clazz.getConstructor(Context::class.java, String::class.java) - constructor.newInstance(context, resolvedAppId) as OpenIapProtocol - } catch (e: Throwable) { - OpenIapModule(context) as OpenIapProtocol - } - } else { - OpenIapModule(context) as OpenIapProtocol - } + OpenIapLog.d("Loading OpenIapModule (Horizon flavor)", "OpenIapStore") + loadHorizonModule(context) } else -> { // Default to Play Store (includes "play", "google", "gplay", "googleplay", "gms") - OpenIapModule(context) as OpenIapProtocol + OpenIapLog.d("Loading OpenIapModule (Play flavor)", "OpenIapStore") + loadPlayModule(context) } } } -private fun isHorizonEnvironment(context: Context): Boolean { - val manufacturer = android.os.Build.MANUFACTURER.lowercase() - if (manufacturer.contains("meta") || manufacturer.contains("oculus")) return true +/** + * Load OpenIapModule (Horizon flavor) via reflection + * Note: Horizon flavor now uses the same package and class name as Play flavor + * App ID is read from AndroidManifest.xml by the Horizon module + */ +private fun loadHorizonModule(context: Context): OpenIapProtocol { return try { - context.packageManager.getPackageInfo("com.oculus.vrshell", 0) - true - } catch (_: Throwable) { - false + // Both Play and Horizon flavors now use the same class name: dev.hyo.openiap.OpenIapModule + val clazz = Class.forName("dev.hyo.openiap.OpenIapModule") + val alternativeBillingModeClass = Class.forName("dev.hyo.openiap.AlternativeBillingMode") + val userChoiceBillingListenerClass = Class.forName("dev.hyo.openiap.listener.UserChoiceBillingListener") + + val constructor = clazz.getConstructor( + Context::class.java, + alternativeBillingModeClass, + userChoiceBillingListenerClass + ) + + // Get NONE enum value + val noneMode = alternativeBillingModeClass.enumConstants?.first { + (it as Enum<*>).name == "NONE" + } + + val instance = constructor.newInstance(context, noneMode, null) as OpenIapProtocol + OpenIapLog.d("Successfully loaded OpenIapModule (Horizon flavor)", "OpenIapStore") + instance + } catch (e: Throwable) { + throw IllegalStateException("Failed to load OpenIapModule (Horizon flavor). Make sure you're using the Horizon flavor.", e) + } +} + +/** + * Load OpenIapModule (Play flavor) via reflection + */ +private fun loadPlayModule(context: Context): OpenIapProtocol { + return try { + // Try to load OpenIapModule with default parameters (Context, NONE mode, null listener) + val clazz = Class.forName("dev.hyo.openiap.OpenIapModule") + val alternativeBillingModeClass = Class.forName("dev.hyo.openiap.AlternativeBillingMode") + val userChoiceBillingListenerClass = Class.forName("dev.hyo.openiap.listener.UserChoiceBillingListener") + + val constructor = clazz.getConstructor( + Context::class.java, + alternativeBillingModeClass, + userChoiceBillingListenerClass + ) + + // Get NONE enum value + val noneMode = alternativeBillingModeClass.enumConstants?.first { + (it as Enum<*>).name == "NONE" + } + + constructor.newInstance(context, noneMode, null) as OpenIapProtocol + } catch (e: Throwable) { + throw IllegalStateException("Failed to load OpenIapModule. Make sure you're using the Play flavor.", e) } } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ProductExtensions.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ProductExtensions.kt new file mode 100644 index 00000000..0145bec5 --- /dev/null +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ProductExtensions.kt @@ -0,0 +1,33 @@ +package dev.hyo.openiap.utils + +import dev.hyo.openiap.Product +import dev.hyo.openiap.ProductAndroid +import dev.hyo.openiap.ProductSubscriptionAndroid +import dev.hyo.openiap.Purchase +import dev.hyo.openiap.PurchaseInput + +/** + * Convert ProductSubscriptionAndroid to Product + * This extension is used by OpenIapStore to add subscriptions to the products list + */ +fun ProductSubscriptionAndroid.toProduct(): Product = ProductAndroid( + currency = currency, + debugDescription = debugDescription, + description = description, + displayName = displayName, + displayPrice = displayPrice, + id = id, + nameAndroid = nameAndroid, + oneTimePurchaseOfferDetailsAndroid = oneTimePurchaseOfferDetailsAndroid, + platform = platform, + price = price, + subscriptionOfferDetailsAndroid = subscriptionOfferDetailsAndroid, + title = title, + type = type +) + +/** + * Convert Purchase to PurchaseInput + * Both types are compatible in the GraphQL schema + */ +fun Purchase.toPurchaseInput(): PurchaseInput = this diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapErrorExtensions.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapErrorExtensions.kt new file mode 100644 index 00000000..90cd11bb --- /dev/null +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapErrorExtensions.kt @@ -0,0 +1,24 @@ +package dev.hyo.openiap + +import com.android.billingclient.api.BillingClient + +/** + * Extension function for converting Google Play Billing response codes to OpenIapError + */ +@Suppress("DEPRECATION") +fun OpenIapError.Companion.fromBillingResponseCode(responseCode: Int, debugMessage: String? = null): OpenIapError { + return when (responseCode) { + BillingClient.BillingResponseCode.USER_CANCELED -> OpenIapError.UserCancelled + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> OpenIapError.ServiceUnavailable + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> OpenIapError.BillingUnavailable + BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> OpenIapError.ItemUnavailable + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> OpenIapError.DeveloperError + BillingClient.BillingResponseCode.ERROR -> OpenIapError.BillingError + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> OpenIapError.ItemAlreadyOwned + BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> OpenIapError.ItemNotOwned + BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> OpenIapError.ServiceDisconnected + BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> OpenIapError.FeatureNotSupported + BillingClient.BillingResponseCode.SERVICE_TIMEOUT -> OpenIapError.ServiceTimeout + else -> OpenIapError.UnknownError + } +} diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt similarity index 99% rename from packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt rename to packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 789093d2..78d56232 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -66,17 +66,7 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.resume import java.lang.ref.WeakReference -/** - * Alternative billing mode - */ -enum class AlternativeBillingMode { - /** Standard Google Play billing (default) */ - NONE, - /** Alternative billing with user choice (user selects between Google Play or alternative) */ - USER_CHOICE, - /** Alternative billing only (no Google Play option) */ - ALTERNATIVE_ONLY -} +// AlternativeBillingMode moved to main source set (shared between Play and Horizon) /** * Main OpenIapModule implementation for Android @@ -767,7 +757,7 @@ class OpenIapModule( } } - override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported } + override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.FeatureNotSupported } private val purchaseError: SubscriptionPurchaseErrorHandler = { onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapViewModel.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapViewModel.kt new file mode 100644 index 00000000..aa9e9f7a --- /dev/null +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapViewModel.kt @@ -0,0 +1,84 @@ +package dev.hyo.openiap + +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import android.app.Application +import dev.hyo.openiap.store.OpenIapStore +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * Android ViewModel wrapper around OpenIapStore for easy integration + */ +class OpenIapViewModel(app: Application) : AndroidViewModel(app) { + private val store = OpenIapStore(app.applicationContext) + + val isConnected: StateFlow = store.isConnected + val products = store.products + val availablePurchases = store.availablePurchases + val status = store.status + + fun initConnection(config: InitConnectionConfig? = null) { + viewModelScope.launch { runCatching { store.initConnection(config) } } + } + fun endConnection() { viewModelScope.launch { runCatching { store.endConnection() } } } + + fun fetchProducts(skus: List, type: ProductQueryType = ProductQueryType.All) { + viewModelScope.launch { + runCatching { + val request = ProductRequest(skus = skus, type = type) + store.fetchProducts(request) + } + } + } + + fun restorePurchases() { + viewModelScope.launch { + runCatching { + store.getAvailablePurchases(null) + } + } + } + + fun requestPurchase(skus: List, type: ProductQueryType = ProductQueryType.InApp) { + viewModelScope.launch { + runCatching { + val props = when (type) { + ProductQueryType.InApp -> { + val android = RequestPurchaseAndroidProps( + isOfferPersonalized = null, + obfuscatedAccountIdAndroid = null, + obfuscatedProfileIdAndroid = null, + skus = skus + ) + RequestPurchaseProps( + request = RequestPurchaseProps.Request.Purchase( + RequestPurchasePropsByPlatforms(android = android) + ), + type = type + ) + } + ProductQueryType.Subs -> { + val android = RequestSubscriptionAndroidProps( + isOfferPersonalized = null, + obfuscatedAccountIdAndroid = null, + obfuscatedProfileIdAndroid = null, + purchaseTokenAndroid = null, + replacementModeAndroid = null, + skus = skus, + subscriptionOffers = null + ) + RequestPurchaseProps( + request = RequestPurchaseProps.Request.Subscription( + RequestSubscriptionPropsByPlatforms(android = android) + ), + type = type + ) + } + else -> throw IllegalArgumentException("type must be InApp or Subs") + } + store.requestPurchase(props) + } + } + } +} diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/Helpers.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt similarity index 91% rename from packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/Helpers.kt rename to packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt index 919c3031..f7546a47 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/Helpers.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt @@ -115,17 +115,15 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { val android = payload.value.android ?: throw IllegalArgumentException("Android subscription parameters are required") - // For subscription upgrades/downgrades, obfuscatedProfileIdAndroid and purchaseTokenAndroid - // are mutually exclusive. If purchaseTokenAndroid is provided (upgrade scenario), - // we should not send obfuscatedProfileIdAndroid to avoid "Invalid arguments" error - val isUpgrade = !android.purchaseTokenAndroid.isNullOrEmpty() - val effectiveObfuscatedProfileId = if (isUpgrade) null else android.obfuscatedProfileIdAndroid - + // For subscription upgrades/downgrades: + // - purchaseTokenAndroid: Identifies which existing subscription to upgrade/downgrade + // - obfuscatedProfileId: Optional user identifier for fraud prevention and attribution + // Both can be provided together - they serve different purposes and are not mutually exclusive AndroidPurchaseArgs( skus = android.skus, isOfferPersonalized = android.isOfferPersonalized, obfuscatedAccountId = android.obfuscatedAccountIdAndroid, - obfuscatedProfileId = effectiveObfuscatedProfileId, + obfuscatedProfileId = android.obfuscatedProfileIdAndroid, purchaseTokenAndroid = android.purchaseTokenAndroid, replacementModeAndroid = android.replacementModeAndroid, subscriptionOffers = android.subscriptionOffers, diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ProductManager.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt similarity index 100% rename from packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/ProductManager.kt rename to packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt deleted file mode 100644 index f7053cc8..00000000 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt +++ /dev/null @@ -1,131 +0,0 @@ -package dev.hyo.openiap.horizon - -import android.app.Activity -import android.content.Context -import dev.hyo.openiap.MutationAcknowledgePurchaseAndroidHandler -import dev.hyo.openiap.MutationConsumePurchaseAndroidHandler -import dev.hyo.openiap.MutationDeepLinkToSubscriptionsHandler -import dev.hyo.openiap.MutationEndConnectionHandler -import dev.hyo.openiap.MutationFinishTransactionHandler -import dev.hyo.openiap.MutationHandlers -import dev.hyo.openiap.MutationInitConnectionHandler -import dev.hyo.openiap.MutationRequestPurchaseHandler -import dev.hyo.openiap.MutationRestorePurchasesHandler -import dev.hyo.openiap.MutationValidateReceiptHandler -import dev.hyo.openiap.OpenIapModule -import dev.hyo.openiap.OpenIapProtocol -import dev.hyo.openiap.QueryFetchProductsHandler -import dev.hyo.openiap.QueryGetActiveSubscriptionsHandler -import dev.hyo.openiap.QueryGetAvailablePurchasesHandler -import dev.hyo.openiap.QueryHandlers -import dev.hyo.openiap.QueryHasActiveSubscriptionsHandler -import dev.hyo.openiap.SubscriptionHandlers -import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener -import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener -import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener - -/** - * Play flavor stub that reuses the Play Billing pipeline. - * Build the `horizon` product flavor to include Horizon billing dependencies. - */ -@Suppress("UNUSED_PARAMETER") -class OpenIapHorizonModule( - context: Context, - appId: String? = null -) : OpenIapProtocol { - - private val delegate = OpenIapModule(context) - - override fun setActivity(activity: Activity?) { - delegate.setActivity(activity) - } - - override val initConnection: MutationInitConnectionHandler - get() = delegate.initConnection - - override val endConnection: MutationEndConnectionHandler - get() = delegate.endConnection - - override val fetchProducts: QueryFetchProductsHandler - get() = delegate.fetchProducts - - override val getAvailablePurchases: QueryGetAvailablePurchasesHandler - get() = delegate.getAvailablePurchases - - override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler - get() = delegate.getActiveSubscriptions - - override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler - get() = delegate.hasActiveSubscriptions - - override val requestPurchase: MutationRequestPurchaseHandler - get() = delegate.requestPurchase - - override val finishTransaction: MutationFinishTransactionHandler - get() = delegate.finishTransaction - - override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler - get() = delegate.acknowledgePurchaseAndroid - - override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler - get() = delegate.consumePurchaseAndroid - - override val restorePurchases: MutationRestorePurchasesHandler - get() = delegate.restorePurchases - - override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler - get() = delegate.deepLinkToSubscriptions - - override val validateReceipt: MutationValidateReceiptHandler - get() = delegate.validateReceipt - - override val queryHandlers: QueryHandlers - get() = delegate.queryHandlers - - override val mutationHandlers: MutationHandlers - get() = delegate.mutationHandlers - - override val subscriptionHandlers: SubscriptionHandlers - get() = delegate.subscriptionHandlers - - override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { - delegate.addPurchaseUpdateListener(listener) - } - - override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { - delegate.removePurchaseUpdateListener(listener) - } - - override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { - delegate.addPurchaseErrorListener(listener) - } - - override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { - delegate.removePurchaseErrorListener(listener) - } - - // Alternative Billing (delegate to OpenIapModule) - override suspend fun checkAlternativeBillingAvailability(): Boolean { - return delegate.checkAlternativeBillingAvailability() - } - - override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean { - return delegate.showAlternativeBillingInformationDialog(activity) - } - - override suspend fun createAlternativeBillingReportingToken(): String? { - return delegate.createAlternativeBillingReportingToken() - } - - override fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { - delegate.setUserChoiceBillingListener(listener) - } - - override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { - delegate.addUserChoiceBillingListener(listener) - } - - override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { - delegate.removeUserChoiceBillingListener(listener) - } -} diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt new file mode 100644 index 00000000..6d15d7df --- /dev/null +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt @@ -0,0 +1,39 @@ +package dev.hyo.openiap.store + +import android.content.Context +import dev.hyo.openiap.AlternativeBillingMode +import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.listener.UserChoiceBillingListener + +/** + * Play-specific extensions for OpenIapStore + * These constructors are only available in the Play flavor + */ + +/** + * Convenience constructor that creates OpenIapModule with alternative billing support + * + * @param context Android context + * @param alternativeBillingMode Alternative billing mode (default: NONE) + * @param userChoiceBillingListener Listener for user choice billing selection (optional) + */ +fun OpenIapStore( + context: Context, + alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE, + userChoiceBillingListener: UserChoiceBillingListener? = null +): OpenIapStore = OpenIapStore(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener) as OpenIapProtocol) + +/** + * Convenience constructor for backward compatibility + * + * @param context Android context + * @param enableAlternativeBilling Enable alternative billing mode (uses ALTERNATIVE_ONLY mode) + */ +@Deprecated("Use constructor with AlternativeBillingMode instead", ReplaceWith("OpenIapStore(context, if (enableAlternativeBilling) AlternativeBillingMode.ALTERNATIVE_ONLY else AlternativeBillingMode.NONE)")) +fun OpenIapStore( + context: Context, + enableAlternativeBilling: Boolean +): OpenIapStore = OpenIapStore( + OpenIapModule(context, if (enableAlternativeBilling) AlternativeBillingMode.ALTERNATIVE_ONLY else AlternativeBillingMode.NONE) as OpenIapProtocol +) diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt similarity index 91% rename from packages/google/openiap/src/main/java/dev/hyo/openiap/utils/BillingConverters.kt rename to packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt index c9d81d9c..1224934e 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -139,20 +139,4 @@ fun PurchaseAndroid.toActiveSubscription(): ActiveSubscription = ActiveSubscript transactionId = id ) -fun ProductSubscriptionAndroid.toProduct(): Product = ProductAndroid( - currency = currency, - debugDescription = debugDescription, - description = description, - displayName = displayName, - displayPrice = displayPrice, - id = id, - nameAndroid = nameAndroid, - oneTimePurchaseOfferDetailsAndroid = oneTimePurchaseOfferDetailsAndroid, - platform = platform, - price = price, - subscriptionOfferDetailsAndroid = subscriptionOfferDetailsAndroid, - title = title, - type = type -) - -fun Purchase.toPurchaseInput(): PurchaseInput = this +// toProduct() and toPurchaseInput() moved to main/utils/ProductExtensions.kt to be shared across flavors 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 c48c59a9..c893482b 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 @@ -84,12 +84,6 @@ class OpenIapErrorTest { assertEquals("Unknown error", error.message) } - @Test - fun `NotSupported has correct code and message`() { - val error = OpenIapError.NotSupported - assertEquals(ErrorCode.FeatureNotSupported.rawValue, error.code) - assertEquals("Operation not supported", error.message) - } @Test fun `NotPrepared has correct code and message`() { @@ -165,7 +159,7 @@ class OpenIapErrorTest { fun `ServiceUnavailable has correct code and message`() { val error = OpenIapError.ServiceUnavailable assertEquals(ErrorCode.ServiceError.rawValue, error.code) - assertEquals("Google Play service is unavailable", error.message) + assertEquals("Billing service is unavailable", error.message) } @Test @@ -206,8 +200,8 @@ class OpenIapErrorTest { @Test fun `ServiceTimeout has correct code and message`() { val error = OpenIapError.ServiceTimeout - assertEquals(ErrorCode.ServiceDisconnected.rawValue, error.code) - assertEquals("The request has reached the maximum timeout before Google Play responds", error.message) + assertEquals("service-timeout", error.code) + assertEquals("The request has reached the maximum timeout before billing service responds", error.message) } @Test @@ -224,7 +218,6 @@ class OpenIapErrorTest { OpenIapError.VerificationFailed, OpenIapError.RestoreFailed, OpenIapError.UnknownError, - OpenIapError.NotSupported, OpenIapError.NotPrepared, OpenIapError.InitConnection, OpenIapError.QueryProduct, @@ -277,34 +270,36 @@ class OpenIapErrorTest { fun `getAllErrorCodes returns all error codes and messages`() { val allCodes = OpenIapError.getAllErrorCodes() - // Check that all expected codes are present + // Check that all expected codes are present (using actual object CODEs) val expectedCodes = setOf( - ErrorCode.SkuNotFound.rawValue, - ErrorCode.PurchaseError.rawValue, - ErrorCode.UserCancelled.rawValue, - ErrorCode.DeferredPayment.rawValue, - ErrorCode.NetworkError.rawValue, - ErrorCode.Unknown.rawValue, - ErrorCode.NotPrepared.rawValue, - ErrorCode.InitConnection.rawValue, - ErrorCode.QueryProduct.rawValue, - ErrorCode.EmptySkuList.rawValue, - ErrorCode.SkuNotFound.rawValue, - ErrorCode.SkuOfferMismatch.rawValue, - ErrorCode.UserCancelled.rawValue, - ErrorCode.AlreadyOwned.rawValue, - ErrorCode.ItemNotOwned.rawValue, - ErrorCode.BillingUnavailable.rawValue, - ErrorCode.ItemUnavailable.rawValue, - ErrorCode.DeveloperError.rawValue, - ErrorCode.FeatureNotSupported.rawValue, - ErrorCode.ServiceDisconnected.rawValue, - ErrorCode.UserError.rawValue, - ErrorCode.ServiceError.rawValue, - ErrorCode.ReceiptFailed.rawValue, - ErrorCode.TransactionValidationFailed.rawValue, - ErrorCode.SyncError.rawValue, - ErrorCode.ActivityUnavailable.rawValue + OpenIapError.ProductNotFound.CODE, + OpenIapError.PurchaseFailed.CODE, + OpenIapError.PurchaseCancelled.CODE, + OpenIapError.PurchaseDeferred.CODE, + OpenIapError.NetworkError.CODE, + OpenIapError.UnknownError.CODE, + OpenIapError.NotPrepared.CODE, + OpenIapError.InitConnection.CODE, + OpenIapError.QueryProduct.CODE, + OpenIapError.EmptySkuList.CODE, + OpenIapError.SkuNotFound.CODE, + OpenIapError.SkuOfferMismatch.CODE, + OpenIapError.UserCancelled.CODE, + OpenIapError.ItemAlreadyOwned.CODE, + OpenIapError.ItemNotOwned.CODE, + OpenIapError.ServiceUnavailable.CODE, + OpenIapError.BillingUnavailable.CODE, + OpenIapError.ItemUnavailable.CODE, + OpenIapError.DeveloperError.CODE, + OpenIapError.FeatureNotSupported.CODE, + OpenIapError.ServiceDisconnected.CODE, + OpenIapError.ServiceTimeout.CODE, + OpenIapError.PaymentNotAllowed.CODE, + OpenIapError.BillingError.CODE, + OpenIapError.InvalidReceipt.CODE, + OpenIapError.VerificationFailed.CODE, + OpenIapError.RestoreFailed.CODE, + OpenIapError.MissingCurrentActivity.CODE ) assertEquals(expectedCodes.size, allCodes.size) @@ -328,7 +323,6 @@ class OpenIapErrorTest { OpenIapError.VerificationFailed to ErrorCode.TransactionValidationFailed.rawValue, OpenIapError.RestoreFailed to ErrorCode.SyncError.rawValue, OpenIapError.UnknownError to ErrorCode.Unknown.rawValue, - OpenIapError.NotSupported to ErrorCode.FeatureNotSupported.rawValue, OpenIapError.NotPrepared to ErrorCode.NotPrepared.rawValue, OpenIapError.InitConnection to ErrorCode.InitConnection.rawValue, OpenIapError.QueryProduct to ErrorCode.QueryProduct.rawValue, @@ -345,7 +339,7 @@ class OpenIapErrorTest { OpenIapError.DeveloperError to ErrorCode.DeveloperError.rawValue, OpenIapError.FeatureNotSupported to ErrorCode.FeatureNotSupported.rawValue, OpenIapError.ServiceDisconnected to ErrorCode.ServiceDisconnected.rawValue, - OpenIapError.ServiceTimeout to ErrorCode.ServiceDisconnected.rawValue + OpenIapError.ServiceTimeout to "service-timeout" ) errors.forEach { (error, expectedCode) ->