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() {
-
-{`// 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 {
-
- autoDebug - Auto-detects platform (Google Play or Horizon OS)
-
- -
- horizonDebug - Forces Horizon OS billing (for Quest devices)
+ horizonDebug - Horizon OS billing (for Quest devices)
-
- playDebug - Forces Google Play billing (for phones/tablets)
+ playDebug - Google Play billing (for phones/tablets)
+
+ 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:
- Open "Build Variants" panel (View โ Tool Windows โ Build Variants)
- - Select your desired variant (e.g., "autoDebug", "horizonDebug", "playDebug")
+ - Select your desired variant (e.g., "horizonDebug" or "playDebug")
- 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) ->