From f2455fa9bcef01f43e75a4c3ad8fa65719f86787 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Sun, 19 Oct 2025 13:03:11 +0900
Subject: [PATCH 1/9] refactor: make play flavor independent
---
.gitignore | 1 -
.vscode/extensions.json | 10 +
.vscode/launch.json | 38 +++
.vscode/settings.json | 44 +++
.vscode/tasks.json | 248 ++++++++++++++
gql/.vscode/settings.json | 11 +
packages/apple/.vscode/launch.json | 54 +++
packages/apple/.vscode/settings.json | 16 +
packages/google/Example/build.gradle.kts | 16 +-
.../Example/src/horizon/AndroidManifest.xml | 12 -
packages/google/openiap/build.gradle.kts | 45 +--
.../java/dev/hyo/openiap/OpenIapError.kt | 318 ++++++++++++++++++
.../openiap/horizon/OpenIapHorizonModule.kt | 6 +-
.../horizon/helpers/HorizonSharedHelpers.kt | 117 +++++++
.../MissingCurrentActivityException.kt | 6 -
.../dev/hyo/openiap/store/OpenIapStore.kt | 62 ++--
.../hyo/openiap/utils/ProductExtensions.kt | 25 ++
.../java/dev/hyo/openiap/OpenIapError.kt | 0
.../java/dev/hyo/openiap/OpenIapModule.kt | 0
.../java/dev/hyo/openiap/OpenIapViewModel.kt | 0
.../java/dev/hyo/openiap/helpers/Helpers.kt | 0
.../dev/hyo/openiap/helpers/ProductManager.kt | 0
.../openiap/horizon/OpenIapHorizonModule.kt | 131 --------
.../openiap/store/OpenIapStoreExtensions.kt | 39 +++
.../hyo/openiap/utils/BillingConverters.kt | 16 -
25 files changed, 971 insertions(+), 244 deletions(-)
create mode 100644 .vscode/extensions.json
create mode 100644 .vscode/launch.json
create mode 100644 .vscode/settings.json
create mode 100644 .vscode/tasks.json
create mode 100644 gql/.vscode/settings.json
create mode 100644 packages/apple/.vscode/launch.json
create mode 100644 packages/apple/.vscode/settings.json
delete mode 100644 packages/google/Example/src/horizon/AndroidManifest.xml
create mode 100644 packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapError.kt
create mode 100644 packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonSharedHelpers.kt
delete mode 100644 packages/google/openiap/src/main/java/dev/hyo/openiap/helpers/MissingCurrentActivityException.kt
create mode 100644 packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ProductExtensions.kt
rename packages/google/openiap/src/{main => play}/java/dev/hyo/openiap/OpenIapError.kt (100%)
rename packages/google/openiap/src/{main => play}/java/dev/hyo/openiap/OpenIapModule.kt (100%)
rename packages/google/openiap/src/{main => play}/java/dev/hyo/openiap/OpenIapViewModel.kt (100%)
rename packages/google/openiap/src/{main => play}/java/dev/hyo/openiap/helpers/Helpers.kt (100%)
rename packages/google/openiap/src/{main => play}/java/dev/hyo/openiap/helpers/ProductManager.kt (100%)
delete mode 100644 packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
create mode 100644 packages/google/openiap/src/play/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt
rename packages/google/openiap/src/{main => play}/java/dev/hyo/openiap/utils/BillingConverters.kt (92%)
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/google/Example/build.gradle.kts b/packages/google/Example/build.gradle.kts
index 447a77f6..a277890e 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 only (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..0aea4059 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,24 @@ 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 Billing Compatibility Library (compile + runtime)
+ 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 +111,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/OpenIapError.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapError.kt
new file mode 100644
index 00000000..7c9761e5
--- /dev/null
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapError.kt
@@ -0,0 +1,318 @@
+package dev.hyo.openiap
+
+import com.meta.horizon.billingclient.api.BillingClient
+
+/**
+ * OpenIAP specific exceptions
+ */
+sealed class OpenIapError : Exception() {
+ abstract val code: String
+ abstract override val message: String
+
+ fun toJSON(): Map = mapOf(
+ "code" to toCode(this),
+ "message" to (this.message ?: ""),
+ "platform" to "android",
+ )
+
+ class ProductNotFound(val productId: String) : OpenIapError() {
+ val CODE = ErrorCode.SkuNotFound.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ companion object {
+ val CODE = ErrorCode.SkuNotFound.rawValue
+ const val MESSAGE = "Product not found"
+ }
+ }
+
+ object PurchaseFailed : OpenIapError() {
+ val CODE = ErrorCode.PurchaseError.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ const val MESSAGE = "Purchase failed"
+ }
+
+ object PurchaseCancelled : OpenIapError() {
+ val CODE = ErrorCode.UserCancelled.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Purchase was cancelled by the user"
+ }
+
+ object PurchaseDeferred : OpenIapError() {
+ val CODE = ErrorCode.DeferredPayment.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Purchase was deferred"
+ }
+
+ object PaymentNotAllowed : OpenIapError() {
+ val CODE = ErrorCode.UserError.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Payment not allowed"
+ }
+
+ object BillingError : OpenIapError() {
+ val CODE = ErrorCode.ServiceError.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ const val MESSAGE = "Billing error"
+ }
+
+ object InvalidReceipt : OpenIapError() {
+ val CODE = ErrorCode.ReceiptFailed.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ const val MESSAGE = "Invalid receipt"
+ }
+
+ object NetworkError : OpenIapError() {
+ val CODE = ErrorCode.NetworkError.rawValue
+ const val MESSAGE = "Network connection error"
+ override val code: String = CODE
+ override val message: String = MESSAGE
+ }
+
+ object VerificationFailed : OpenIapError() {
+ val CODE = ErrorCode.TransactionValidationFailed.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ const val MESSAGE = "Verification failed"
+ }
+
+ object RestoreFailed : OpenIapError() {
+ val CODE = ErrorCode.SyncError.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ const val MESSAGE = "Restore failed"
+ }
+
+ object UnknownError : OpenIapError() {
+ val CODE = ErrorCode.Unknown.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ 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"
+ override val code: String = CODE
+ override val message: String = MESSAGE
+ }
+
+ object InitConnection : OpenIapError() {
+ val CODE = ErrorCode.InitConnection.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ const val MESSAGE = "Failed to initialize billing connection"
+ }
+
+ object QueryProduct : OpenIapError() {
+ val CODE = ErrorCode.QueryProduct.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ const val MESSAGE = "Failed to query product"
+ }
+
+ object EmptySkuList : OpenIapError() {
+ const val CODE = "empty-sku-list"
+ const val MESSAGE = "SKU list cannot be empty"
+ override val code: String = CODE
+ override val message: String = MESSAGE
+ }
+
+ class SkuNotFound(val sku: String) : OpenIapError() {
+ val CODE = ErrorCode.SkuNotFound.rawValue
+ override val code = CODE
+ override val message = MESSAGE
+
+ companion object {
+ val CODE = ErrorCode.SkuNotFound.rawValue
+ const val MESSAGE = "SKU not found"
+ }
+ }
+
+ object SkuOfferMismatch : OpenIapError() {
+ const val CODE = "sku-offer-mismatch"
+ const val MESSAGE = "SKU and offer token count mismatch"
+ override val code: String = CODE
+ override val message: String = MESSAGE
+ }
+
+ object MissingCurrentActivity : OpenIapError() {
+ val CODE = ErrorCode.ActivityUnavailable.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Current activity is not available"
+ }
+
+ object UserCancelled : OpenIapError() {
+ val CODE = ErrorCode.UserCancelled.rawValue
+ const val MESSAGE = "User cancelled the operation"
+ override val code: String = CODE
+ override val message: String = MESSAGE
+ }
+
+ object ItemAlreadyOwned : OpenIapError() {
+ val CODE = ErrorCode.AlreadyOwned.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Item is already owned"
+ }
+
+ object ItemNotOwned : OpenIapError() {
+ val CODE = ErrorCode.ItemNotOwned.rawValue
+ const val MESSAGE = "Item is not owned"
+ override val code: String = CODE
+ override val message: String = MESSAGE
+ }
+
+ object ServiceUnavailable : OpenIapError() {
+ val CODE = ErrorCode.ServiceError.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Google Play service is unavailable"
+ }
+
+ object BillingUnavailable : OpenIapError() {
+ val CODE = ErrorCode.BillingUnavailable.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Billing API version is not supported"
+ }
+
+ object ItemUnavailable : OpenIapError() {
+ val CODE = ErrorCode.ItemUnavailable.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Requested product is not available for purchase"
+ }
+
+ object DeveloperError : OpenIapError() {
+ val CODE = ErrorCode.DeveloperError.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Invalid arguments provided to the API"
+ }
+
+ object FeatureNotSupported : OpenIapError() {
+ val CODE = ErrorCode.FeatureNotSupported.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Requested feature is not supported by Play Store"
+ }
+
+ object ServiceDisconnected : OpenIapError() {
+ val CODE = ErrorCode.ServiceDisconnected.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "Play Store service is not connected"
+ }
+
+ object ServiceTimeout : OpenIapError() {
+ val CODE = ErrorCode.ServiceDisconnected.rawValue
+ override val code: String = CODE
+ override val message: String = MESSAGE
+
+ const val MESSAGE = "The request has reached the maximum timeout before Google Play responds"
+ }
+
+ class AlternativeBillingUnavailable(val details: String) : OpenIapError() {
+ val CODE = ErrorCode.BillingUnavailable.rawValue
+ override val code = CODE
+ override val message = details
+
+ companion object {
+ val CODE = ErrorCode.BillingUnavailable.rawValue
+ }
+ }
+
+ 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
+ )
+ }
+
+ fun toCode(error: OpenIapError): String = error.code
+
+ 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/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
index 921189ce..94a721a6 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
@@ -59,9 +59,9 @@ 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.onPurchaseError
+import dev.hyo.openiap.horizon.helpers.onPurchaseUpdated
+import dev.hyo.openiap.horizon.helpers.toAndroidPurchaseArgs
import dev.hyo.openiap.horizon.helpers.restorePurchasesHorizon
import dev.hyo.openiap.horizon.helpers.queryPurchasesHorizon
import dev.hyo.openiap.horizon.helpers.HorizonProductManager
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonSharedHelpers.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonSharedHelpers.kt
new file mode 100644
index 00000000..79e4e3c4
--- /dev/null
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonSharedHelpers.kt
@@ -0,0 +1,117 @@
+package dev.hyo.openiap.horizon.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")
+
+ val isUpgrade = !android.purchaseTokenAndroid.isNullOrEmpty()
+ val effectiveObfuscatedProfileId = if (isUpgrade) null else android.obfuscatedProfileIdAndroid
+
+ AndroidPurchaseArgs(
+ skus = android.skus,
+ isOfferPersonalized = android.isOfferPersonalized,
+ obfuscatedAccountId = android.obfuscatedAccountIdAndroid,
+ obfuscatedProfileId = effectiveObfuscatedProfileId,
+ 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/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..72b0ace9 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
@@ -35,7 +35,7 @@ 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
@@ -61,30 +61,7 @@ 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)
-
- /**
- * 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)
+ // Play-specific alternative billing constructors moved to play/store/OpenIapStoreExtensions.kt
// Public state
private val _isConnected = MutableStateFlow(false)
@@ -593,7 +570,7 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI
} catch (e: Throwable) {
// Fallback to Play Store implementation
OpenIapLog.e("Failed to load OpenIapHorizonModule, falling back to Play", e, "OpenIapStore")
- OpenIapModule(context) as OpenIapProtocol
+ loadPlayModule(context)
}
}
"auto" -> {
@@ -604,15 +581,15 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI
val constructor = clazz.getConstructor(Context::class.java, String::class.java)
constructor.newInstance(context, resolvedAppId) as OpenIapProtocol
} catch (e: Throwable) {
- OpenIapModule(context) as OpenIapProtocol
+ loadPlayModule(context)
}
} else {
- OpenIapModule(context) as OpenIapProtocol
+ loadPlayModule(context)
}
}
else -> {
// Default to Play Store (includes "play", "google", "gplay", "googleplay", "gms")
- OpenIapModule(context) as OpenIapProtocol
+ loadPlayModule(context)
}
}
}
@@ -627,3 +604,30 @@ private fun isHorizonEnvironment(context: Context): Boolean {
false
}
}
+
+/**
+ * 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..25851fd0
--- /dev/null
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/ProductExtensions.kt
@@ -0,0 +1,25 @@
+package dev.hyo.openiap.utils
+
+import dev.hyo.openiap.Product
+import dev.hyo.openiap.ProductAndroid
+import dev.hyo.openiap.ProductSubscriptionAndroid
+
+/**
+ * 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
+)
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapError.kt
similarity index 100%
rename from packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
rename to packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapError.kt
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 100%
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
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt b/packages/google/openiap/src/play/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/play/java/dev/hyo/openiap/OpenIapViewModel.kt
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 100%
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
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 92%
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..4ea4347a 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
From 58154b03745d4a45e06bd87b743f22668790a695 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Sun, 19 Oct 2025 13:05:58 +0900
Subject: [PATCH 2/9] refactor: make horizon flavor independent
---
.../openiap/horizon/OpenIapHorizonModule.kt | 13 ++++++-
.../openiap/store/OpenIapStoreExtensions.kt | 27 +++++++++++++
.../dev/hyo/openiap/AlternativeBillingMode.kt | 14 +++++++
.../dev/hyo/openiap/store/OpenIapStore.kt | 39 +++++++++++++++----
.../hyo/openiap/utils/ProductExtensions.kt | 8 ++++
.../java/dev/hyo/openiap/OpenIapModule.kt | 12 +-----
.../hyo/openiap/utils/BillingConverters.kt | 2 +-
7 files changed, 94 insertions(+), 21 deletions(-)
create mode 100644 packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt
create mode 100644 packages/google/openiap/src/main/java/dev/hyo/openiap/AlternativeBillingMode.kt
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/horizon/OpenIapHorizonModule.kt
index 94a721a6..f8ee9207 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
@@ -19,6 +19,7 @@ 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.AlternativeBillingMode
import dev.hyo.openiap.FetchProductsResult
import dev.hyo.openiap.FetchProductsResultProducts
import dev.hyo.openiap.FetchProductsResultSubscriptions
@@ -84,9 +85,19 @@ import kotlin.coroutines.resumeWithException
private const val TAG = "OpenIapHorizonModule"
+/**
+ * OpenIapHorizonModule for Meta Horizon Billing
+ *
+ * @param context Android context
+ * @param appId Oculus App ID (optional, will be read from manifest if not provided)
+ * @param alternativeBillingMode Alternative billing mode (default: NONE)
+ * @param userChoiceBillingListener Listener for user choice billing selection (optional)
+ */
class OpenIapHorizonModule(
private val context: Context,
- private val appId: String? = null
+ private val appId: String? = null,
+ private var alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE,
+ private var userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null
) : OpenIapProtocol, PurchasesUpdatedListener {
companion object {
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..76e70031
--- /dev/null
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt
@@ -0,0 +1,27 @@
+package dev.hyo.openiap.store
+
+import android.content.Context
+import dev.hyo.openiap.AlternativeBillingMode
+import dev.hyo.openiap.horizon.OpenIapHorizonModule
+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
+ */
+
+/**
+ * Convenience constructor that creates OpenIapHorizonModule with alternative billing support
+ *
+ * @param context Android context
+ * @param appId Oculus App ID
+ * @param alternativeBillingMode Alternative billing mode (default: NONE)
+ * @param userChoiceBillingListener Listener for user choice billing selection (optional)
+ */
+fun OpenIapStore(
+ context: Context,
+ appId: String,
+ alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE,
+ userChoiceBillingListener: UserChoiceBillingListener? = null
+): OpenIapStore = OpenIapStore(OpenIapHorizonModule(context, appId, alternativeBillingMode, userChoiceBillingListener) as OpenIapProtocol)
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/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
index 72b0ace9..d3d2f6de 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
@@ -562,11 +562,7 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI
"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
+ loadHorizonModule(context, resolvedAppId)
} catch (e: Throwable) {
// Fallback to Play Store implementation
OpenIapLog.e("Failed to load OpenIapHorizonModule, falling back to Play", e, "OpenIapStore")
@@ -577,9 +573,7 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI
// 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
+ loadHorizonModule(context, resolvedAppId)
} catch (e: Throwable) {
loadPlayModule(context)
}
@@ -605,6 +599,35 @@ private fun isHorizonEnvironment(context: Context): Boolean {
}
}
+/**
+ * Load OpenIapHorizonModule (Horizon flavor) via reflection
+ */
+private fun loadHorizonModule(context: Context, appId: String): OpenIapProtocol {
+ return try {
+ val clazz = Class.forName("dev.hyo.openiap.horizon.OpenIapHorizonModule")
+ val alternativeBillingModeClass = Class.forName("dev.hyo.openiap.AlternativeBillingMode")
+ val userChoiceBillingListenerClass = Class.forName("dev.hyo.openiap.listener.UserChoiceBillingListener")
+
+ val constructor = clazz.getConstructor(
+ Context::class.java,
+ String::class.java,
+ alternativeBillingModeClass,
+ userChoiceBillingListenerClass
+ )
+
+ // Get NONE enum value
+ val noneMode = alternativeBillingModeClass.enumConstants?.first {
+ (it as Enum<*>).name == "NONE"
+ }
+
+ val instance = constructor.newInstance(context, appId, noneMode, null) as OpenIapProtocol
+ OpenIapLog.d("Successfully loaded OpenIapHorizonModule", "OpenIapStore")
+ instance
+ } catch (e: Throwable) {
+ throw IllegalStateException("Failed to load OpenIapHorizonModule. Make sure you're using the Horizon flavor.", e)
+ }
+}
+
/**
* Load OpenIapModule (Play flavor) via reflection
*/
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
index 25851fd0..0145bec5 100644
--- 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
@@ -3,6 +3,8 @@ 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
@@ -23,3 +25,9 @@ fun ProductSubscriptionAndroid.toProduct(): Product = ProductAndroid(
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/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
index 789093d2..481c0d0f 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
@@ -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
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
index 4ea4347a..1224934e 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt
@@ -139,4 +139,4 @@ fun PurchaseAndroid.toActiveSubscription(): ActiveSubscription = ActiveSubscript
transactionId = id
)
-fun Purchase.toPurchaseInput(): PurchaseInput = this
+// toProduct() and toPurchaseInput() moved to main/utils/ProductExtensions.kt to be shared across flavors
From c40468fadd98b074166219b5e6d5ccdcc4e9a837 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Sun, 19 Oct 2025 20:49:23 +0900
Subject: [PATCH 3/9] refactor(google): unify horizon and play flavor
constructors
---
...enIapHorizonModule.kt => OpenIapModule.kt} | 104 ++++++++----------
.../java/dev/hyo/openiap/OpenIapViewModel.kt | 84 ++++++++++++++
.../HorizonHelpers.kt => helpers/Helpers.kt} | 8 +-
.../ProductManager.kt} | 6 +-
.../SharedHelpers.kt} | 2 +-
.../openiap/store/OpenIapStoreExtensions.kt | 11 +-
.../BillingConverters.kt} | 2 +-
7 files changed, 142 insertions(+), 75 deletions(-)
rename packages/google/openiap/src/horizon/java/dev/hyo/openiap/{horizon/OpenIapHorizonModule.kt => OpenIapModule.kt} (93%)
create mode 100644 packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt
rename packages/google/openiap/src/horizon/java/dev/hyo/openiap/{horizon/helpers/HorizonHelpers.kt => helpers/Helpers.kt} (95%)
rename packages/google/openiap/src/horizon/java/dev/hyo/openiap/{horizon/helpers/HorizonProductManager.kt => helpers/ProductManager.kt} (97%)
rename packages/google/openiap/src/horizon/java/dev/hyo/openiap/{horizon/helpers/HorizonSharedHelpers.kt => helpers/SharedHelpers.kt} (99%)
rename packages/google/openiap/src/horizon/java/dev/hyo/openiap/{horizon/utils/HorizonBillingConverters.kt => utils/BillingConverters.kt} (99%)
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 93%
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 f8ee9207..0db04d40 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,59 +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.AlternativeBillingMode
-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.horizon.helpers.onPurchaseError
-import dev.hyo.openiap.horizon.helpers.onPurchaseUpdated
-import dev.hyo.openiap.horizon.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.onPurchaseError
+import dev.hyo.openiap.helpers.onPurchaseUpdated
+import dev.hyo.openiap.helpers.toAndroidPurchaseArgs
+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
@@ -83,25 +44,25 @@ import java.lang.ref.WeakReference
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
-private const val TAG = "OpenIapHorizonModule"
+private const val TAG = "OpenIapModule"
/**
- * OpenIapHorizonModule for Meta Horizon Billing
+ * OpenIapModule for Meta Horizon Billing
*
* @param context Android context
- * @param appId Oculus App ID (optional, will be read from manifest if not provided)
* @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 OpenIapHorizonModule(
+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()
@@ -110,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)
@@ -121,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()
}
@@ -844,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/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt
new file mode 100644
index 00000000..aa9e9f7a
--- /dev/null
+++ b/packages/google/openiap/src/horizon/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/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/horizon/helpers/HorizonSharedHelpers.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt
similarity index 99%
rename from packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonSharedHelpers.kt
rename to packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt
index 79e4e3c4..8343987e 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonSharedHelpers.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt
@@ -1,4 +1,4 @@
-package dev.hyo.openiap.horizon.helpers
+package dev.hyo.openiap.helpers
import dev.hyo.openiap.AndroidSubscriptionOfferInput
import dev.hyo.openiap.ErrorCode
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt
index 76e70031..13cd2824 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/store/OpenIapStoreExtensions.kt
@@ -2,26 +2,27 @@ package dev.hyo.openiap.store
import android.content.Context
import dev.hyo.openiap.AlternativeBillingMode
-import dev.hyo.openiap.horizon.OpenIapHorizonModule
+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 OpenIapHorizonModule with alternative billing support
+ * Convenience constructor that creates OpenIapModule (Horizon flavor) with alternative billing support
*
* @param context Android context
- * @param appId Oculus App ID
* @param alternativeBillingMode Alternative billing mode (default: NONE)
* @param userChoiceBillingListener Listener for user choice billing selection (optional)
*/
fun OpenIapStore(
context: Context,
- appId: String,
alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE,
userChoiceBillingListener: UserChoiceBillingListener? = null
-): OpenIapStore = OpenIapStore(OpenIapHorizonModule(context, appId, alternativeBillingMode, userChoiceBillingListener) as OpenIapProtocol)
+): 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
From feb9aeb3f7e73318de6f07e8b6a577d0e5229bc0 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Mon, 20 Oct 2025 00:51:28 +0900
Subject: [PATCH 4/9] refactor: replace GlobalScope with scoped coroutines
---
packages/google/openiap/build.gradle.kts | 4 +-
.../java/dev/hyo/openiap/OpenIapModule.kt | 2 +-
.../dev/hyo/openiap/store/OpenIapStore.kt | 44 +++++++++----------
3 files changed, 26 insertions(+), 24 deletions(-)
diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts
index 0aea4059..a517777b 100644
--- a/packages/google/openiap/build.gradle.kts
+++ b/packages/google/openiap/build.gradle.kts
@@ -79,7 +79,9 @@ dependencies {
add("playCompileOnly", "com.android.billingclient:billing-ktx:8.0.0")
add("playApi", "com.android.billingclient:billing-ktx:8.0.0")
- // Horizon flavor: Meta Horizon Billing Compatibility Library (compile + runtime)
+ // 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")
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
index 0db04d40..1d16eeab 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
@@ -663,7 +663,7 @@ class OpenIapModule(
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 {
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 d3d2f6de..963e8105 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
@@ -42,6 +42,8 @@ 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
@@ -63,6 +65,9 @@ class OpenIapStore(private val module: OpenIapProtocol) {
// Play-specific alternative billing constructors moved to play/store/OpenIapStoreExtensions.kt
+ // Coroutine scope for background operations
+ private val storeScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
// Public state
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow = _isConnected.asStateFlow()
@@ -103,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")
@@ -191,6 +196,7 @@ class OpenIapStore(private val module: OpenIapProtocol) {
module.removePurchaseErrorListener(purchaseErrorListener)
processedPurchaseTokens.clear()
pendingRequestProductId = null
+ storeScope.cancel()
}
// -------------------------------------------------------------------------
@@ -560,29 +566,22 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI
return when (selected) {
"horizon", "meta", "quest" -> {
- try {
- OpenIapLog.d("Loading OpenIapHorizonModule with appId=$resolvedAppId", "OpenIapStore")
- loadHorizonModule(context, resolvedAppId)
- } catch (e: Throwable) {
- // Fallback to Play Store implementation
- OpenIapLog.e("Failed to load OpenIapHorizonModule, falling back to Play", e, "OpenIapStore")
- loadPlayModule(context)
- }
+ OpenIapLog.d("Loading OpenIapModule (Horizon flavor)", "OpenIapStore")
+ loadHorizonModule(context, resolvedAppId)
}
"auto" -> {
- // Auto-detect environment
- if (isHorizonEnvironment(context)) {
- try {
- loadHorizonModule(context, resolvedAppId)
- } catch (e: Throwable) {
- loadPlayModule(context)
- }
+ // Auto-detect environment based on BuildConfig or runtime detection
+ if (defaultStore == "horizon" || isHorizonEnvironment(context)) {
+ OpenIapLog.d("Auto-detected Horizon environment, loading Horizon flavor", "OpenIapStore")
+ loadHorizonModule(context, resolvedAppId)
} else {
+ OpenIapLog.d("Auto-detected Play environment, loading Play flavor", "OpenIapStore")
loadPlayModule(context)
}
}
else -> {
// Default to Play Store (includes "play", "google", "gplay", "googleplay", "gms")
+ OpenIapLog.d("Loading OpenIapModule (Play flavor)", "OpenIapStore")
loadPlayModule(context)
}
}
@@ -600,17 +599,18 @@ private fun isHorizonEnvironment(context: Context): Boolean {
}
/**
- * Load OpenIapHorizonModule (Horizon flavor) via reflection
+ * Load OpenIapModule (Horizon flavor) via reflection
+ * Note: Horizon flavor now uses the same package and class name as Play flavor
*/
private fun loadHorizonModule(context: Context, appId: String): OpenIapProtocol {
return try {
- val clazz = Class.forName("dev.hyo.openiap.horizon.OpenIapHorizonModule")
+ // 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,
- String::class.java,
alternativeBillingModeClass,
userChoiceBillingListenerClass
)
@@ -620,11 +620,11 @@ private fun loadHorizonModule(context: Context, appId: String): OpenIapProtocol
(it as Enum<*>).name == "NONE"
}
- val instance = constructor.newInstance(context, appId, noneMode, null) as OpenIapProtocol
- OpenIapLog.d("Successfully loaded OpenIapHorizonModule", "OpenIapStore")
+ 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 OpenIapHorizonModule. Make sure you're using the Horizon flavor.", e)
+ throw IllegalStateException("Failed to load OpenIapModule (Horizon flavor). Make sure you're using the Horizon flavor.", e)
}
}
From 5e35e5349df7dd9962e79c3fd9f45dec944d5854 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Mon, 20 Oct 2025 01:25:33 +0900
Subject: [PATCH 5/9] refactor(google): move OpenIapError to main sourceset
---
packages/google/Example/build.gradle.kts | 2 +-
.../java/dev/hyo/openiap/OpenIapError.kt | 318 ------------------
.../java/dev/hyo/openiap/OpenIapError.kt | 0
3 files changed, 1 insertion(+), 319 deletions(-)
delete mode 100644 packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapError.kt
rename packages/google/openiap/src/{play => main}/java/dev/hyo/openiap/OpenIapError.kt (100%)
diff --git a/packages/google/Example/build.gradle.kts b/packages/google/Example/build.gradle.kts
index a277890e..8b961afe 100644
--- a/packages/google/Example/build.gradle.kts
+++ b/packages/google/Example/build.gradle.kts
@@ -40,7 +40,7 @@ android {
flavorDimensions += "platform"
productFlavors {
- // Play flavor - Google Play Billing only (default)
+ // Play flavor - Google Play Billing (default)
create("play") {
dimension = "platform"
buildConfigField("String", "OPENIAP_STORE", "\"play\"")
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapError.kt
deleted file mode 100644
index 7c9761e5..00000000
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapError.kt
+++ /dev/null
@@ -1,318 +0,0 @@
-package dev.hyo.openiap
-
-import com.meta.horizon.billingclient.api.BillingClient
-
-/**
- * OpenIAP specific exceptions
- */
-sealed class OpenIapError : Exception() {
- abstract val code: String
- abstract override val message: String
-
- fun toJSON(): Map = mapOf(
- "code" to toCode(this),
- "message" to (this.message ?: ""),
- "platform" to "android",
- )
-
- class ProductNotFound(val productId: String) : OpenIapError() {
- val CODE = ErrorCode.SkuNotFound.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- companion object {
- val CODE = ErrorCode.SkuNotFound.rawValue
- const val MESSAGE = "Product not found"
- }
- }
-
- object PurchaseFailed : OpenIapError() {
- val CODE = ErrorCode.PurchaseError.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- const val MESSAGE = "Purchase failed"
- }
-
- object PurchaseCancelled : OpenIapError() {
- val CODE = ErrorCode.UserCancelled.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Purchase was cancelled by the user"
- }
-
- object PurchaseDeferred : OpenIapError() {
- val CODE = ErrorCode.DeferredPayment.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Purchase was deferred"
- }
-
- object PaymentNotAllowed : OpenIapError() {
- val CODE = ErrorCode.UserError.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Payment not allowed"
- }
-
- object BillingError : OpenIapError() {
- val CODE = ErrorCode.ServiceError.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- const val MESSAGE = "Billing error"
- }
-
- object InvalidReceipt : OpenIapError() {
- val CODE = ErrorCode.ReceiptFailed.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- const val MESSAGE = "Invalid receipt"
- }
-
- object NetworkError : OpenIapError() {
- val CODE = ErrorCode.NetworkError.rawValue
- const val MESSAGE = "Network connection error"
- override val code: String = CODE
- override val message: String = MESSAGE
- }
-
- object VerificationFailed : OpenIapError() {
- val CODE = ErrorCode.TransactionValidationFailed.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- const val MESSAGE = "Verification failed"
- }
-
- object RestoreFailed : OpenIapError() {
- val CODE = ErrorCode.SyncError.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- const val MESSAGE = "Restore failed"
- }
-
- object UnknownError : OpenIapError() {
- val CODE = ErrorCode.Unknown.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- 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"
- override val code: String = CODE
- override val message: String = MESSAGE
- }
-
- object InitConnection : OpenIapError() {
- val CODE = ErrorCode.InitConnection.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- const val MESSAGE = "Failed to initialize billing connection"
- }
-
- object QueryProduct : OpenIapError() {
- val CODE = ErrorCode.QueryProduct.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- const val MESSAGE = "Failed to query product"
- }
-
- object EmptySkuList : OpenIapError() {
- const val CODE = "empty-sku-list"
- const val MESSAGE = "SKU list cannot be empty"
- override val code: String = CODE
- override val message: String = MESSAGE
- }
-
- class SkuNotFound(val sku: String) : OpenIapError() {
- val CODE = ErrorCode.SkuNotFound.rawValue
- override val code = CODE
- override val message = MESSAGE
-
- companion object {
- val CODE = ErrorCode.SkuNotFound.rawValue
- const val MESSAGE = "SKU not found"
- }
- }
-
- object SkuOfferMismatch : OpenIapError() {
- const val CODE = "sku-offer-mismatch"
- const val MESSAGE = "SKU and offer token count mismatch"
- override val code: String = CODE
- override val message: String = MESSAGE
- }
-
- object MissingCurrentActivity : OpenIapError() {
- val CODE = ErrorCode.ActivityUnavailable.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Current activity is not available"
- }
-
- object UserCancelled : OpenIapError() {
- val CODE = ErrorCode.UserCancelled.rawValue
- const val MESSAGE = "User cancelled the operation"
- override val code: String = CODE
- override val message: String = MESSAGE
- }
-
- object ItemAlreadyOwned : OpenIapError() {
- val CODE = ErrorCode.AlreadyOwned.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Item is already owned"
- }
-
- object ItemNotOwned : OpenIapError() {
- val CODE = ErrorCode.ItemNotOwned.rawValue
- const val MESSAGE = "Item is not owned"
- override val code: String = CODE
- override val message: String = MESSAGE
- }
-
- object ServiceUnavailable : OpenIapError() {
- val CODE = ErrorCode.ServiceError.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Google Play service is unavailable"
- }
-
- object BillingUnavailable : OpenIapError() {
- val CODE = ErrorCode.BillingUnavailable.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Billing API version is not supported"
- }
-
- object ItemUnavailable : OpenIapError() {
- val CODE = ErrorCode.ItemUnavailable.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Requested product is not available for purchase"
- }
-
- object DeveloperError : OpenIapError() {
- val CODE = ErrorCode.DeveloperError.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Invalid arguments provided to the API"
- }
-
- object FeatureNotSupported : OpenIapError() {
- val CODE = ErrorCode.FeatureNotSupported.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Requested feature is not supported by Play Store"
- }
-
- object ServiceDisconnected : OpenIapError() {
- val CODE = ErrorCode.ServiceDisconnected.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "Play Store service is not connected"
- }
-
- object ServiceTimeout : OpenIapError() {
- val CODE = ErrorCode.ServiceDisconnected.rawValue
- override val code: String = CODE
- override val message: String = MESSAGE
-
- const val MESSAGE = "The request has reached the maximum timeout before Google Play responds"
- }
-
- class AlternativeBillingUnavailable(val details: String) : OpenIapError() {
- val CODE = ErrorCode.BillingUnavailable.rawValue
- override val code = CODE
- override val message = details
-
- companion object {
- val CODE = ErrorCode.BillingUnavailable.rawValue
- }
- }
-
- 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
- )
- }
-
- fun toCode(error: OpenIapError): String = error.code
-
- 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/play/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
similarity index 100%
rename from packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapError.kt
rename to packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
From 9e001d35cb6592e5ce753baf63178d1f9e16b6d3 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Mon, 20 Oct 2025 01:40:07 +0900
Subject: [PATCH 6/9] refactor(google): remove auto flavor and extract billing
extensions
---
.../docs/src/pages/docs/horizon-setup.tsx | 63 +++++++++----------
.../dev/hyo/openiap/OpenIapErrorExtensions.kt | 24 +++++++
.../main/java/dev/hyo/openiap/OpenIapError.kt | 20 ------
.../dev/hyo/openiap/store/OpenIapStore.kt | 23 +------
.../dev/hyo/openiap/OpenIapErrorExtensions.kt | 24 +++++++
5 files changed, 77 insertions(+), 77 deletions(-)
create mode 100644 packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapErrorExtensions.kt
create mode 100644 packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapErrorExtensions.kt
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/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/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
index 36462370..9a7379c9 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
*/
@@ -294,24 +292,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/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
index 963e8105..c8a528bd 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
@@ -544,7 +544,7 @@ 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()) {
+ val resolvedAppId = if ((selected == "horizon" || selected == "meta" || selected == "quest") && appId.isNullOrEmpty()) {
try {
val applicationInfo = context.packageManager.getApplicationInfo(
context.packageName,
@@ -569,16 +569,6 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI
OpenIapLog.d("Loading OpenIapModule (Horizon flavor)", "OpenIapStore")
loadHorizonModule(context, resolvedAppId)
}
- "auto" -> {
- // Auto-detect environment based on BuildConfig or runtime detection
- if (defaultStore == "horizon" || isHorizonEnvironment(context)) {
- OpenIapLog.d("Auto-detected Horizon environment, loading Horizon flavor", "OpenIapStore")
- loadHorizonModule(context, resolvedAppId)
- } else {
- OpenIapLog.d("Auto-detected Play environment, loading Play flavor", "OpenIapStore")
- loadPlayModule(context)
- }
- }
else -> {
// Default to Play Store (includes "play", "google", "gplay", "googleplay", "gms")
OpenIapLog.d("Loading OpenIapModule (Play flavor)", "OpenIapStore")
@@ -587,17 +577,6 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI
}
}
-private fun isHorizonEnvironment(context: Context): Boolean {
- val manufacturer = android.os.Build.MANUFACTURER.lowercase()
- if (manufacturer.contains("meta") || manufacturer.contains("oculus")) return true
- return try {
- context.packageManager.getPackageInfo("com.oculus.vrshell", 0)
- true
- } catch (_: Throwable) {
- false
- }
-}
-
/**
* Load OpenIapModule (Horizon flavor) via reflection
* Note: Horizon flavor now uses the same package and class name as Play flavor
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
+ }
+}
From 41af263de6be38e7763c072710e05bc5b101800f Mon Sep 17 00:00:00 2001
From: hyochan
Date: Mon, 20 Oct 2025 01:48:12 +0900
Subject: [PATCH 7/9] fix: code review
---
.github/workflows/ci.yml | 36 ++++++++--
.../java/dev/hyo/openiap/OpenIapModule.kt | 2 +-
.../main/java/dev/hyo/openiap/OpenIapError.kt | 68 +++++++++----------
.../dev/hyo/openiap/store/OpenIapStore.kt | 29 ++------
.../java/dev/hyo/openiap/OpenIapModule.kt | 2 +-
.../java/dev/hyo/openiap/OpenIapErrorTest.kt | 14 +---
6 files changed, 74 insertions(+), 77 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c32ac0bf..949d111b 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 || {
+ 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 || {
+ 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 || {
+ 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 || {
+ echo "Attempt $i failed. Retrying..."
+ sleep 5
+ }
+ done
- name: Type check
working-directory: packages/docs
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
index 1d16eeab..34f6d261 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
@@ -628,7 +628,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/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt
index 9a7379c9..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
@@ -103,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"
@@ -194,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() {
@@ -238,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() {
@@ -258,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
)
}
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 c8a528bd..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
@@ -326,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)
}
@@ -543,31 +543,13 @@ 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") && 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" -> {
OpenIapLog.d("Loading OpenIapModule (Horizon flavor)", "OpenIapStore")
- loadHorizonModule(context, resolvedAppId)
+ loadHorizonModule(context)
}
else -> {
// Default to Play Store (includes "play", "google", "gplay", "googleplay", "gms")
@@ -580,8 +562,9 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI
/**
* 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, appId: String): OpenIapProtocol {
+private fun loadHorizonModule(context: Context): OpenIapProtocol {
return try {
// Both Play and Horizon flavors now use the same class name: dev.hyo.openiap.OpenIapModule
val clazz = Class.forName("dev.hyo.openiap.OpenIapModule")
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
index 481c0d0f..78d56232 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
@@ -757,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/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt
index c48c59a9..21d0235c 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`() {
@@ -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,
@@ -328,7 +321,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 +337,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) ->
From 653fff0a6973c37ab80dcb355319c26fff8dec48 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Mon, 20 Oct 2025 01:54:36 +0900
Subject: [PATCH 8/9] fix: ci
---
.../java/dev/hyo/openiap/OpenIapErrorTest.kt | 58 ++++++++++---------
1 file changed, 30 insertions(+), 28 deletions(-)
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 21d0235c..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
@@ -159,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
@@ -270,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)
From 7bc53e6b31238380f16800d4fe514da6cbc2fd2d Mon Sep 17 00:00:00 2001
From: hyochan
Date: Mon, 20 Oct 2025 01:59:32 +0900
Subject: [PATCH 9/9] fix: code review
---
.github/workflows/ci.yml | 32 +++++++++----------
.../dev/hyo/openiap/helpers/SharedHelpers.kt | 9 +++---
.../java/dev/hyo/openiap/helpers/Helpers.kt | 12 +++----
3 files changed, 26 insertions(+), 27 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 949d111b..c0915163 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,10 +25,10 @@ jobs:
run: |
# Retry bun install up to 3 times to handle transient registry errors
for i in 1 2 3; do
- bun install && break || {
- echo "Attempt $i failed. Retrying..."
- sleep 5
- }
+ bun install && break
+ [ $i -eq 3 ] && exit 1
+ echo "Attempt $i failed. Retrying..."
+ sleep 5
done
- name: Generate types
@@ -66,10 +66,10 @@ jobs:
run: |
# Retry bun install up to 3 times to handle transient registry errors
for i in 1 2 3; do
- bun install && break || {
- echo "Attempt $i failed. Retrying..."
- sleep 5
- }
+ bun install && break
+ [ $i -eq 3 ] && exit 1
+ echo "Attempt $i failed. Retrying..."
+ sleep 5
done
- name: Generate types
@@ -105,10 +105,10 @@ jobs:
run: |
# Retry bun install up to 3 times to handle transient registry errors
for i in 1 2 3; do
- bun install && break || {
- echo "Attempt $i failed. Retrying..."
- sleep 5
- }
+ bun install && break
+ [ $i -eq 3 ] && exit 1
+ echo "Attempt $i failed. Retrying..."
+ sleep 5
done
- name: Generate types
@@ -139,10 +139,10 @@ jobs:
run: |
# Retry bun install up to 3 times to handle transient registry errors
for i in 1 2 3; do
- bun install && break || {
- echo "Attempt $i failed. Retrying..."
- sleep 5
- }
+ bun install && break
+ [ $i -eq 3 ] && exit 1
+ echo "Attempt $i failed. Retrying..."
+ sleep 5
done
- name: Type check
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
index 8343987e..93b4cdd5 100644
--- 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
@@ -85,14 +85,15 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs {
val android = payload.value.android
?: throw IllegalArgumentException("Android subscription parameters are required")
- 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/play/java/dev/hyo/openiap/helpers/Helpers.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
index 919c3031..f7546a47 100644
--- a/packages/google/openiap/src/play/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,